From 6dd56f23d1f218fea990b0cca70115b0684e22a9 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 19 Dec 2024 17:13:04 -0800 Subject: [PATCH 001/149] added KeyManager class --- lib/services/key_manager.dart | 23 +++++++++++++++++++++++ lib/shared/utils/nostr_utils.dart | 20 -------------------- 2 files changed, 23 insertions(+), 20 deletions(-) create mode 100644 lib/services/key_manager.dart diff --git a/lib/services/key_manager.dart b/lib/services/key_manager.dart new file mode 100644 index 00000000..4f1b97f7 --- /dev/null +++ b/lib/services/key_manager.dart @@ -0,0 +1,23 @@ +import 'package:bip32_bip44/dart_bip32_bip44.dart' as bip32; +import 'package:bip39/bip39.dart' as bip39; + +class KeyManager { + static String getPrivateKeyFromMnemonic(String mnemonic, int index) { + final seed = bip39.mnemonicToSeedHex(mnemonic); + final chain = bip32.Chain.seed(seed); + + final key = + chain.forPath("m/44'/1237'/38383'/0/0") as bip32.ExtendedPrivateKey; + final childKey = bip32.deriveExtendedPrivateChildKey(key, index); + return (childKey.key != null) ? childKey.key!.toRadixString(16) : ''; + } + + static String generateMnemonic() { + return bip39.generateMnemonic(); + } + + static bool isMnemonicValid(String text) { + return bip39.validateMnemonic(text); + } + +} diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 5b686d29..e036aab0 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -2,8 +2,6 @@ import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; -import 'package:bip32_bip44/dart_bip32_bip44.dart' as bip32_bip44; -import 'package:bip39/bip39.dart' as bip39; import 'package:elliptic/elliptic.dart'; import 'package:nip44/nip44.dart'; @@ -303,22 +301,4 @@ class NostrUtils { throw Exception('Decryption failed: $e'); } } - - static String getPrivateKeyFromMnemonic(String mnemonic, int index) { - final seed = bip39.mnemonicToSeedHex(mnemonic); - final chain = bip32_bip44.Chain.seed(seed); - - final key = chain.forPath("m/44'/1237'/38383'/0/0") - as bip32_bip44.ExtendedPrivateKey; - final childKey = bip32_bip44.deriveExtendedPrivateChildKey(key, index); - return (childKey.key != null) ? childKey.key!.toRadixString(16) : ''; - } - - static bool isMnemonicValid(String text) { - return bip39.validateMnemonic(text); - } - - static String getMnemonic() { - return bip39.generateMnemonic(); - } } From 5d7bca8cf2e51189c687044b9135dcd604636bb9 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 21 Dec 2024 01:02:18 -0800 Subject: [PATCH 002/149] Add key manager and related providers --- lib/main.dart | 4 ++ lib/services/key_manager.dart | 66 ++++++++++++++++--- .../notifiers/app_settings_controller.dart | 2 +- lib/shared/providers/init_provider.dart | 14 ++++ .../providers/key_manager_provider.dart | 9 +++ ...s_provider.dart => storage_providers.dart} | 5 ++ lib/shared/utils/nostr_utils.dart | 20 ++++++ 7 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 lib/shared/providers/init_provider.dart create mode 100644 lib/shared/providers/key_manager_provider.dart rename lib/shared/providers/{shared_preferences_provider.dart => storage_providers.dart} (56%) diff --git a/lib/main.dart b/lib/main.dart index ae74c58b..ada39c7d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,9 @@ import 'package:mostro_mobile/app/app.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; import 'package:mostro_mobile/shared/utils/biometrics_helper.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -12,12 +14,14 @@ void main() async { final nostrService = NostrService(); await nostrService.init(); final biometricsHelper = BiometricsHelper(); + final sharedPreferences = SharedPreferencesAsync(); runApp( ProviderScope( overrides: [ nostrServicerProvider.overrideWithValue(nostrService), biometricsHelperProvider.overrideWithValue(biometricsHelper), + sharedPreferencesProvider.overrideWithValue(sharedPreferences), ], child: const MostroApp(), ), diff --git a/lib/services/key_manager.dart b/lib/services/key_manager.dart index 4f1b97f7..8e65d424 100644 --- a/lib/services/key_manager.dart +++ b/lib/services/key_manager.dart @@ -1,15 +1,24 @@ -import 'package:bip32_bip44/dart_bip32_bip44.dart' as bip32; +import 'package:bip32_bip44/dart_bip32_bip44.dart'; import 'package:bip39/bip39.dart' as bip39; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class KeyManager { - static String getPrivateKeyFromMnemonic(String mnemonic, int index) { - final seed = bip39.mnemonicToSeedHex(mnemonic); - final chain = bip32.Chain.seed(seed); + final FlutterSecureStorage secureStorage; + final SharedPreferencesAsync sharedPreferences; + + static const String _masterKeyKey = 'master_key'; + static const String _mnemonicKey = 'mnemonic'; + static const String _keyIndexKey = 'key_index'; - final key = - chain.forPath("m/44'/1237'/38383'/0/0") as bip32.ExtendedPrivateKey; - final childKey = bip32.deriveExtendedPrivateChildKey(key, index); - return (childKey.key != null) ? childKey.key!.toRadixString(16) : ''; + KeyManager({required this.secureStorage, required this.sharedPreferences}); + + static String getPrivateKeyFromMnemonic(String mnemonic) { + final seed = bip39.mnemonicToSeedHex(mnemonic); + final chain = Chain.seed(seed); + final key = chain.forPath("m/44'/1237'/38383'/0/0"); + return key.privateKeyHex(); } static String generateMnemonic() { @@ -20,4 +29,45 @@ class KeyManager { return bip39.validateMnemonic(text); } + Future hasMasterKey() async { + String? masterKey = await secureStorage.read(key: _masterKeyKey); + return masterKey != null; + } + + Future getMasterKey() async { + String? masterKeyHex = await secureStorage.read(key: _masterKeyKey); + if (masterKeyHex == null) { + return null; + } + return NostrKeyPairs(private: masterKeyHex); + } + + Future generateAndStoreMasterKey() async { + final mnemonic = bip39.generateMnemonic(); + final key = getPrivateKeyFromMnemonic(mnemonic); + await secureStorage.write(key: _mnemonicKey, value: mnemonic); + await secureStorage.write(key: _masterKeyKey, value: key); + } + + /// Derives a new trade key based on the current key index. + Future deriveTradeKey() async { + int currentIndex = await sharedPreferences.getInt(_keyIndexKey) ?? 0; + final masterKey = await getMasterKey(); + if (masterKey == null) { + throw Exception('Master key not found.'); + } + final chain = Chain.import(masterKey.private); + final key = chain.forPath("m/44'/1237'/38383'/0/0") as ExtendedPrivateKey; + final tradeKey = deriveExtendedPrivateChildKey(key, currentIndex); + + // Increment and save the key index + await sharedPreferences.setInt(_keyIndexKey, currentIndex + 1); + + return NostrKeyPairs(private: tradeKey.privateKeyHex()); + } + + /// Retrieves the current key index + Future getCurrentKeyIndex() async { + return await sharedPreferences.getInt(_keyIndexKey) ?? 0; + } } diff --git a/lib/shared/notifiers/app_settings_controller.dart b/lib/shared/notifiers/app_settings_controller.dart index c383dd78..59e18902 100644 --- a/lib/shared/notifiers/app_settings_controller.dart +++ b/lib/shared/notifiers/app_settings_controller.dart @@ -1,6 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/app/app_settings.dart'; -import 'package:mostro_mobile/shared/providers/shared_preferences_provider.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; class AppSettingsController extends StateNotifier { final Ref ref; diff --git a/lib/shared/providers/init_provider.dart b/lib/shared/providers/init_provider.dart new file mode 100644 index 00000000..2bd1f20c --- /dev/null +++ b/lib/shared/providers/init_provider.dart @@ -0,0 +1,14 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/shared/providers/key_manager_provider.dart'; + +final appInitializerProvider = FutureProvider((ref) async { + final keyManager = ref.read(keyManagerProvider); + + // Check if master key exists + bool hasMaster = await keyManager.hasMasterKey(); + + if (!hasMaster) { + // First run: Generate and store master key + await keyManager.generateAndStoreMasterKey(); + } +}); \ No newline at end of file diff --git a/lib/shared/providers/key_manager_provider.dart b/lib/shared/providers/key_manager_provider.dart new file mode 100644 index 00000000..339c974d --- /dev/null +++ b/lib/shared/providers/key_manager_provider.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/services/key_manager.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; + +final keyManagerProvider = Provider((ref) { + final secureStorage = ref.watch(secureStorageProvider); + final sharedPrefs = ref.watch(sharedPreferencesProvider); + return KeyManager(secureStorage: secureStorage, sharedPreferences: sharedPrefs); +}); \ No newline at end of file diff --git a/lib/shared/providers/shared_preferences_provider.dart b/lib/shared/providers/storage_providers.dart similarity index 56% rename from lib/shared/providers/shared_preferences_provider.dart rename to lib/shared/providers/storage_providers.dart index 1983aea7..c0afb3b4 100644 --- a/lib/shared/providers/shared_preferences_provider.dart +++ b/lib/shared/providers/storage_providers.dart @@ -1,6 +1,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; +final secureStorageProvider = Provider((ref) { + return const FlutterSecureStorage(); +}); + final sharedPreferencesProvider = Provider((ref) { return SharedPreferencesAsync(); }); \ No newline at end of file diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index e036aab0..5b686d29 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; +import 'package:bip32_bip44/dart_bip32_bip44.dart' as bip32_bip44; +import 'package:bip39/bip39.dart' as bip39; import 'package:elliptic/elliptic.dart'; import 'package:nip44/nip44.dart'; @@ -301,4 +303,22 @@ class NostrUtils { throw Exception('Decryption failed: $e'); } } + + static String getPrivateKeyFromMnemonic(String mnemonic, int index) { + final seed = bip39.mnemonicToSeedHex(mnemonic); + final chain = bip32_bip44.Chain.seed(seed); + + final key = chain.forPath("m/44'/1237'/38383'/0/0") + as bip32_bip44.ExtendedPrivateKey; + final childKey = bip32_bip44.deriveExtendedPrivateChildKey(key, index); + return (childKey.key != null) ? childKey.key!.toRadixString(16) : ''; + } + + static bool isMnemonicValid(String text) { + return bip39.validateMnemonic(text); + } + + static String getMnemonic() { + return bip39.generateMnemonic(); + } } From 7ce52634ca904056ba499a6ceb3ad537d624d8a9 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 21 Dec 2024 01:13:39 -0800 Subject: [PATCH 003/149] added full privacy mode setting to app settings --- lib/app/app_settings.dart | 11 ++++++----- lib/shared/notifiers/app_settings_controller.dart | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/app/app_settings.dart b/lib/app/app_settings.dart index 6043ab5b..312589c2 100644 --- a/lib/app/app_settings.dart +++ b/lib/app/app_settings.dart @@ -1,11 +1,12 @@ class AppSettings { - final bool isFirstLaunch; + final bool fullPrivacyMode; - AppSettings(this.isFirstLaunch); + AppSettings({required this.fullPrivacyMode}); - factory AppSettings.intial() => AppSettings(true); + factory AppSettings.intial() => AppSettings(fullPrivacyMode: false); - AppSettings copyWith({bool isFirstLaunch = false}) { - return AppSettings(isFirstLaunch); + AppSettings copyWith({required bool fullPrivacyMode}) { + return AppSettings(fullPrivacyMode: fullPrivacyMode); } + } diff --git a/lib/shared/notifiers/app_settings_controller.dart b/lib/shared/notifiers/app_settings_controller.dart index 59e18902..2764e39a 100644 --- a/lib/shared/notifiers/app_settings_controller.dart +++ b/lib/shared/notifiers/app_settings_controller.dart @@ -10,8 +10,8 @@ class AppSettingsController extends StateNotifier { Future loadSettings() async { final prefs = ref.read(sharedPreferencesProvider); - final isFirstLaunch = await prefs.getBool('isFirstLaunch') ?? true; + final fullPrivacyMode = await prefs.getBool('full_privacy_mode') ?? true; - state = state.copyWith(isFirstLaunch: isFirstLaunch); + state = state.copyWith(fullPrivacyMode: fullPrivacyMode); } } From dace285b73c2288ec74e856c47b447276a6d7508 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 21 Dec 2024 13:00:50 -0800 Subject: [PATCH 004/149] Changed SecureStorageManager to SessionManager --- lib/app/app.dart | 59 ++++++++++++------- lib/app/app_routes.dart | 1 - lib/data/models/session.dart | 7 ++- lib/data/repositories/mostro_repository.dart | 8 +-- ...rage_manager.dart => session_manager.dart} | 10 ++-- lib/main.dart | 3 + lib/services/mostro_service.dart | 4 +- lib/shared/providers/init_provider.dart | 4 -- .../providers/mostro_service_provider.dart | 6 +- .../providers/session_manager_provider.dart | 7 +++ lib/shared/providers/storage_providers.dart | 4 +- 11 files changed, 65 insertions(+), 48 deletions(-) rename lib/data/repositories/{secure_storage_manager.dart => session_manager.dart} (90%) create mode 100644 lib/shared/providers/session_manager_provider.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index c8d3a41a..4960e35f 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -7,34 +7,51 @@ import 'package:mostro_mobile/app/app_theme.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/features/auth/notifiers/auth_state.dart'; +import 'package:mostro_mobile/shared/providers/init_provider.dart'; class MostroApp extends ConsumerWidget { const MostroApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - ref.listen(authNotifierProvider, (previous, state) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - if (state is AuthAuthenticated || state is AuthRegistrationSuccess) { - context.go('/'); - } else if (state is AuthUnregistered || state is AuthUnauthenticated) { - context.go('/welcome'); - } - }); - }); + final initAsyncValue = ref.watch(appInitializerProvider); - return MaterialApp.router( - title: 'Mostro', - theme: AppTheme.theme, - routerConfig: goRouter, - localizationsDelegates: const [ - S.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: S.delegate.supportedLocales, + return initAsyncValue.when( + data: (_) { + ref.listen(authNotifierProvider, (previous, state) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + if (state is AuthAuthenticated || state is AuthRegistrationSuccess) { + context.go('/'); + } else if (state is AuthUnregistered || state is AuthUnauthenticated) { + context.go('/welcome'); + } + }); + }); + + return MaterialApp.router( + title: 'Mostro', + theme: AppTheme.theme, + routerConfig: goRouter, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ); + }, + loading: () => const MaterialApp( + home: Scaffold( + body: Center(child: CircularProgressIndicator()), + ), + ), + error: (err, stack) => MaterialApp( + home: Scaffold( + body: Center(child: Text('Initialization Error: $err')), + ), + ), ); } } diff --git a/lib/app/app_routes.dart b/lib/app/app_routes.dart index 51a81ae5..87a77ce6 100644 --- a/lib/app/app_routes.dart +++ b/lib/app/app_routes.dart @@ -19,7 +19,6 @@ final goRouter = GoRouter( routes: [ ShellRoute( builder: (BuildContext context, GoRouterState state, Widget child) { - // Wrap the Navigator with your listener widgets return NotificationListenerWidget( child: NavigationListenerWidget( child: child, diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index ed004033..da7b1ae6 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -23,14 +23,17 @@ class Session { 'sessionId': sessionId, 'startTime': startTime.toIso8601String(), 'privateKey': keyPair.private, - 'eventId' : eventId, + 'publicKey': keyPair.public, + 'eventId': eventId, }; factory Session.fromJson(Map json) { return Session( sessionId: json['sessionId'], startTime: DateTime.parse(json['startTime']), - keyPair: NostrKeyPairs(private: json['privateKey']), + keyPair: NostrKeyPairs( + private: json['privateKey'], + ), eventId: json['eventId'], ); } diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index 4172cb0c..0e99f0cc 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -8,10 +8,7 @@ import 'package:mostro_mobile/services/mostro_service.dart'; class MostroRepository implements OrderRepository { final MostroService _mostroService; final Map _messages = {}; - final Map> _subscriptions = {}; - - final Map _orderExpirations = {}; final StreamController> _streamController = StreamController>.broadcast(); @@ -19,9 +16,7 @@ class MostroRepository implements OrderRepository { Stream> get ordersStream => _streamController.stream; - MostroMessage? getOrderById(String orderId) { - return _messages[orderId]; - } + MostroMessage? getOrderById(String orderId) => _messages[orderId]; Stream _subscribe(Session session) { return _mostroService.subscribe(session)..listen((m) { @@ -61,7 +56,6 @@ class MostroRepository implements OrderRepository { subscription.cancel(); } _subscriptions.clear(); - _orderExpirations.clear(); _streamController.close(); } } diff --git a/lib/data/repositories/secure_storage_manager.dart b/lib/data/repositories/session_manager.dart similarity index 90% rename from lib/data/repositories/secure_storage_manager.dart rename to lib/data/repositories/session_manager.dart index 9b36a1ff..f6e99eda 100644 --- a/lib/data/repositories/secure_storage_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -4,13 +4,13 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; -class SecureStorageManager { +class SessionManager { Timer? _cleanupTimer; final int sessionExpirationHours = 48; static const cleanupIntervalMinutes = 30; static const maxBatchSize = 100; - SecureStorageManager() { + SessionManager() { _initializeCleanup(); } @@ -61,7 +61,8 @@ class SecureStorageManager { if (sessionJson != null) { try { final session = Session.fromJson(jsonDecode(sessionJson)); - if (now.difference(session.startTime).inHours >= sessionExpirationHours) { + if (now.difference(session.startTime).inHours >= + sessionExpirationHours) { await prefs.remove(key); processedCount++; } @@ -79,7 +80,8 @@ class SecureStorageManager { void _initializeCleanup() { clearExpiredSessions(); - _cleanupTimer = Timer.periodic(Duration(minutes: cleanupIntervalMinutes), (timer) { + _cleanupTimer = + Timer.periodic(Duration(minutes: cleanupIntervalMinutes), (timer) { clearExpiredSessions(); }); } diff --git a/lib/main.dart b/lib/main.dart index ada39c7d..3fce96d1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mostro_mobile/app/app.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; @@ -15,6 +16,7 @@ void main() async { await nostrService.init(); final biometricsHelper = BiometricsHelper(); final sharedPreferences = SharedPreferencesAsync(); + final secureStorage = const FlutterSecureStorage(); runApp( ProviderScope( @@ -22,6 +24,7 @@ void main() async { nostrServicerProvider.overrideWithValue(nostrService), biometricsHelperProvider.overrideWithValue(biometricsHelper), sharedPreferencesProvider.overrideWithValue(sharedPreferences), + secureStorageProvider.overrideWithValue(secureStorage), ], child: const MostroApp(), ), diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index e35b6e19..1fad5772 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -6,13 +6,13 @@ import 'package:mostro_mobile/app/config.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/data/repositories/secure_storage_manager.dart'; +import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; class MostroService { final NostrService _nostrService; //final MostroInstance _instance; - final SecureStorageManager _secureStorageManager; + final SessionManager _secureStorageManager; final _sessions = HashMap(); diff --git a/lib/shared/providers/init_provider.dart b/lib/shared/providers/init_provider.dart index 2bd1f20c..825d36e2 100644 --- a/lib/shared/providers/init_provider.dart +++ b/lib/shared/providers/init_provider.dart @@ -3,12 +3,8 @@ import 'package:mostro_mobile/shared/providers/key_manager_provider.dart'; final appInitializerProvider = FutureProvider((ref) async { final keyManager = ref.read(keyManagerProvider); - - // Check if master key exists bool hasMaster = await keyManager.hasMasterKey(); - if (!hasMaster) { - // First run: Generate and store master key await keyManager.generateAndStoreMasterKey(); } }); \ No newline at end of file diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index 26557d21..94399c0d 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -1,12 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.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/mostro_service.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; - -final sessionManagerProvider = Provider((ref) { - return SecureStorageManager(); -}); +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; final mostroServiceProvider = Provider((ref) { final sessionStorage = ref.watch(sessionManagerProvider); diff --git a/lib/shared/providers/session_manager_provider.dart b/lib/shared/providers/session_manager_provider.dart new file mode 100644 index 00000000..36556aae --- /dev/null +++ b/lib/shared/providers/session_manager_provider.dart @@ -0,0 +1,7 @@ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/repositories/session_manager.dart'; + +final sessionManagerProvider = Provider((ref) { + return SessionManager(); +}); diff --git a/lib/shared/providers/storage_providers.dart b/lib/shared/providers/storage_providers.dart index c0afb3b4..01c8cc3e 100644 --- a/lib/shared/providers/storage_providers.dart +++ b/lib/shared/providers/storage_providers.dart @@ -7,5 +7,5 @@ final secureStorageProvider = Provider((ref) { }); final sharedPreferencesProvider = Provider((ref) { - return SharedPreferencesAsync(); -}); \ No newline at end of file + throw UnimplementedError(); // Overridden in main +}); From 8cd3ac8b3b383bf926144f741d0b9bd851e71f83 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sun, 22 Dec 2024 16:40:22 -0800 Subject: [PATCH 005/149] Moved session storage/retrieval to SessionManager --- lib/constants/storage_keys.dart | 51 ++++++++ lib/data/models/session.dart | 46 +++---- lib/data/repositories/mostro_repository.dart | 2 + lib/data/repositories/session_manager.dart | 82 +++++++----- lib/services/key_manager.dart | 40 ++++-- lib/services/mostro_service.dart | 123 ++++++++---------- .../notifiers/app_settings_controller.dart | 5 +- .../providers/session_manager_provider.dart | 7 +- lib/shared/utils/nostr_utils.dart | 19 --- 9 files changed, 217 insertions(+), 158 deletions(-) create mode 100644 lib/constants/storage_keys.dart diff --git a/lib/constants/storage_keys.dart b/lib/constants/storage_keys.dart new file mode 100644 index 00000000..fe1b1bd6 --- /dev/null +++ b/lib/constants/storage_keys.dart @@ -0,0 +1,51 @@ +enum SharedPreferencesKeys { + keyIndex('key_index'), + fullPrivacy('full_privacy'); + + final String value; + + const SharedPreferencesKeys(this.value); + + static final _valueMap = { + for (var key in SharedPreferencesKeys.values) key.value: key + }; + + static SharedPreferencesKeys fromString(String value) { + final key = _valueMap[value]; + if (key == null) { + throw ArgumentError('Invalid Shared Preferences Key: $value'); + } + return key; + } + + @override + String toString() { + return value; + } +} + +enum SecureStorageKeys { + masterKey('master_key'), + menemoic('mnemonic'); + + final String value; + + const SecureStorageKeys(this.value); + + static final _valueMap = { + for (var key in SecureStorageKeys.values) key.value: key + }; + + static SecureStorageKeys fromString(String value) { + final key = _valueMap[value]; + if (key == null) { + throw ArgumentError('Invalid Secure Storage Key: $value'); + } + return key; + } + + @override + String toString() { + return value; + } +} diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index da7b1ae6..8c417dcc 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -3,41 +3,41 @@ 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 NostrKeyPairs masterKey; + final NostrKeyPairs tradeKey; + final int keyIndex; + final bool fullPrivacy; final DateTime startTime; - final NostrKeyPairs keyPair; - String? eventId; + String? orderId; - Session({ - required this.sessionId, - required this.startTime, - required this.keyPair, - this.eventId, - }); + Session( + {required this.masterKey, + required this.tradeKey, + required this.keyIndex, + required this.startTime, + required this.fullPrivacy, + this.orderId}); Map toJson() => { - 'sessionId': sessionId, 'startTime': startTime.toIso8601String(), - 'privateKey': keyPair.private, - 'publicKey': keyPair.public, - 'eventId': eventId, + 'masterKey': masterKey.private, + 'tradeKey': tradeKey.private, + 'eventId': orderId, + 'keyIndex': keyIndex, + 'fullPrivacy': fullPrivacy, }; factory Session.fromJson(Map json) { return Session( - sessionId: json['sessionId'], startTime: DateTime.parse(json['startTime']), - keyPair: NostrKeyPairs( - private: json['privateKey'], + masterKey: NostrKeyPairs( + private: json['masterKey'], ), - eventId: json['eventId'], + orderId: json['eventId'], + keyIndex: int.parse(json['keyIndex']), + tradeKey: NostrKeyPairs(private: json['tradeKey']), + fullPrivacy: json['fullPrivacy'], ); } - - String get privateKey => keyPair.private; - String get publicKey => keyPair.public; } diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index 0e99f0cc..88e54188 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -8,7 +8,9 @@ import 'package:mostro_mobile/services/mostro_service.dart'; class MostroRepository implements OrderRepository { final MostroService _mostroService; final Map _messages = {}; + final Map> _subscriptions = {}; + final StreamController> _streamController = StreamController>.broadcast(); diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index f6e99eda..3e3e1af1 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -1,78 +1,96 @@ import 'dart:async'; import 'dart:convert'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mostro_mobile/services/key_manager.dart'; import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class SessionManager { + final KeyManager _keyManager; + final FlutterSecureStorage _flutterSecureStorage; + final Map _sessions = {}; + Timer? _cleanupTimer; final int sessionExpirationHours = 48; static const cleanupIntervalMinutes = 30; static const maxBatchSize = 100; - SessionManager() { + SessionManager(this._keyManager, this._flutterSecureStorage) { _initializeCleanup(); } - Future newSession() async { - final keys = NostrUtils.generateKeyPair(); + Future newSession({String? orderId}) async { + final keys = await _keyManager.getMasterKey(); + final keyIndex = await _keyManager.getCurrentKeyIndex(); + final tradeKey = await _keyManager.deriveTradeKey(); final session = Session( - sessionId: keys.public, startTime: DateTime.now(), - keyPair: keys, + masterKey: keys!, + keyIndex: keyIndex, + tradeKey: tradeKey, + fullPrivacy: false, + orderId: orderId, ); + _sessions[keyIndex] = session; 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); + await _flutterSecureStorage.write( + key: session.keyIndex.toString(), value: sessionJson); + } + + Future getSession(int sessionId) async { + if (_sessions.containsKey(sessionId)) { + return _sessions[sessionId]; + } + return await loadSession(sessionId); + } + + Session getSessionByOrderId(String orderId) { + return _sessions.values.firstWhere((s) => s.orderId == orderId); } - Future loadSession(String sessionId) async { - final prefs = await SharedPreferences.getInstance(); - String? sessionJson = prefs.getString(sessionId); + Future loadSession(int sessionId) async { + String? sessionJson = + await _flutterSecureStorage.read(key: sessionId.toString()); 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 deleteSession(int sessionId) async { + _sessions.remove(sessionId); + await _flutterSecureStorage.delete(key: sessionId.toString()); } Future clearExpiredSessions() async { try { - final prefs = await SharedPreferences.getInstance(); final now = DateTime.now(); - final allKeys = prefs.getKeys(); + final allKeys = await _flutterSecureStorage.readAll(); int processedCount = 0; - for (var key in allKeys) { + allKeys.forEach((key, value) async { if (processedCount >= maxBatchSize) { // Schedule remaining cleanup for next run - break; + return; } - 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); + final sessionJson = value; + try { + final session = Session.fromJson(jsonDecode(sessionJson)); + if (now.difference(session.startTime).inHours >= + sessionExpirationHours) { + await _flutterSecureStorage.delete(key: key); processedCount++; } + } catch (e) { + print('Error processing session $key: $e'); + await _flutterSecureStorage.delete(key: key); + processedCount++; } - } + }); } catch (e) { print('Error during session cleanup: $e'); } diff --git a/lib/services/key_manager.dart b/lib/services/key_manager.dart index 8e65d424..0eea749e 100644 --- a/lib/services/key_manager.dart +++ b/lib/services/key_manager.dart @@ -2,16 +2,13 @@ import 'package:bip32_bip44/dart_bip32_bip44.dart'; import 'package:bip39/bip39.dart' as bip39; import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mostro_mobile/constants/storage_keys.dart'; import 'package:shared_preferences/shared_preferences.dart'; class KeyManager { final FlutterSecureStorage secureStorage; final SharedPreferencesAsync sharedPreferences; - static const String _masterKeyKey = 'master_key'; - static const String _mnemonicKey = 'mnemonic'; - static const String _keyIndexKey = 'key_index'; - KeyManager({required this.secureStorage, required this.sharedPreferences}); static String getPrivateKeyFromMnemonic(String mnemonic) { @@ -30,12 +27,14 @@ class KeyManager { } Future hasMasterKey() async { - String? masterKey = await secureStorage.read(key: _masterKeyKey); + String? masterKey = + await secureStorage.read(key: SecureStorageKeys.masterKey.toString()); return masterKey != null; } Future getMasterKey() async { - String? masterKeyHex = await secureStorage.read(key: _masterKeyKey); + String? masterKeyHex = + await secureStorage.read(key: SecureStorageKeys.masterKey.toString()); if (masterKeyHex == null) { return null; } @@ -45,13 +44,17 @@ class KeyManager { Future generateAndStoreMasterKey() async { final mnemonic = bip39.generateMnemonic(); final key = getPrivateKeyFromMnemonic(mnemonic); - await secureStorage.write(key: _mnemonicKey, value: mnemonic); - await secureStorage.write(key: _masterKeyKey, value: key); + await secureStorage.write( + key: SecureStorageKeys.menemoic.toString(), value: mnemonic); + await secureStorage.write( + key: SecureStorageKeys.masterKey.toString(), value: key); } /// Derives a new trade key based on the current key index. Future deriveTradeKey() async { - int currentIndex = await sharedPreferences.getInt(_keyIndexKey) ?? 0; + int currentIndex = await sharedPreferences + .getInt(SharedPreferencesKeys.keyIndex.toString()) ?? + 0; final masterKey = await getMasterKey(); if (masterKey == null) { throw Exception('Master key not found.'); @@ -61,13 +64,28 @@ class KeyManager { final tradeKey = deriveExtendedPrivateChildKey(key, currentIndex); // Increment and save the key index - await sharedPreferences.setInt(_keyIndexKey, currentIndex + 1); + await sharedPreferences.setInt( + SharedPreferencesKeys.keyIndex.toString(), currentIndex + 1); + + return NostrKeyPairs(private: tradeKey.privateKeyHex()); + } + + Future deriveTradeKeyFromIndex(int index) async { + final masterKey = await getMasterKey(); + if (masterKey == null) { + throw Exception('Master key not found.'); + } + final chain = Chain.import(masterKey.private); + final key = chain.forPath("m/44'/1237'/38383'/0/0") as ExtendedPrivateKey; + final tradeKey = deriveExtendedPrivateChildKey(key, index); return NostrKeyPairs(private: tradeKey.privateKeyHex()); } /// Retrieves the current key index Future getCurrentKeyIndex() async { - return await sharedPreferences.getInt(_keyIndexKey) ?? 0; + return await sharedPreferences + .getInt(SharedPreferencesKeys.keyIndex.toString()) ?? + 0; } } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 1fad5772..3f684945 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,6 +1,4 @@ -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/app/config.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; @@ -11,38 +9,27 @@ import 'package:mostro_mobile/services/nostr_service.dart'; class MostroService { final NostrService _nostrService; - //final MostroInstance _instance; - final SessionManager _secureStorageManager; + final SessionManager _sessionManager; - final _sessions = HashMap(); - - MostroService(this._nostrService, this._secureStorageManager); + MostroService(this._nostrService, this._sessionManager); Stream subscribe(Session session) { - final filter = NostrFilter(p: [session.publicKey]); + final filter = NostrFilter(p: [session.masterKey.public]); return _nostrService.subscribeToEvents(filter).asyncMap((event) async { - try { - final decryptedEvent = - await _nostrService.decryptNIP59Event(event, session.privateKey); - final msg = MostroMessage.deserialized(decryptedEvent.content!); - session.eventId = msg.requestId; - return msg; - } catch (e) { - print('Error processing event: $e'); - return MostroMessage(action: Action.canceled, requestId: ""); + final decryptedEvent = await _nostrService.decryptNIP59Event( + event, session.masterKey.private); + final msg = MostroMessage.deserialized(decryptedEvent.content!); + if (session.orderId == null && msg.requestId != null) { + session.orderId = msg.requestId; + await _sessionManager.saveSession(session); } + return msg; }); } - Stream subscribeToOrders(NostrFilter filter) { - return _nostrService.subscribeToEvents(filter); - } - Future takeSellOrder( String orderId, int? amount, String? lnAddress) async { - final session = await _secureStorageManager.newSession(); - session.eventId = orderId; - _sessions[orderId] = session; + final session = await _sessionManager.newSession(orderId: orderId); final order = lnAddress != null ? { @@ -62,21 +49,15 @@ class MostroService { }); final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.privateKey); + content, Config.mostroPubKey, session.masterKey.private); await _nostrService.publishEvent(event); return session; } Future sendInvoice(String orderId, String invoice) async { - final session = _sessions[orderId]; - - if (session == null) { - throw Exception('Session not found for order ID: $orderId'); - } - - final content = jsonEncode({ + final content = { 'order': { - 'version': Config.mostroVersion.toInt(), + 'version': Config.mostroVersion, 'id': orderId, 'action': Action.addInvoice.value, 'content': { @@ -87,18 +68,21 @@ class MostroService { ] }, }, - }); + }; - final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.privateKey); + try { + final session = _sessionManager.getSessionByOrderId(orderId); + final event = await _nostrService.createNIP59Event( + jsonEncode(content), Config.mostroPubKey, session.masterKey.private); - await _nostrService.publishEvent(event); + await _nostrService.publishEvent(event); + } catch (e) { + // check and log error kinds + } } Future takeBuyOrder(String orderId, int? amount) async { - final session = await _secureStorageManager.newSession(); - session.eventId = orderId; - _sessions[orderId] = session; + final session = await _sessionManager.newSession(orderId: orderId); final content = jsonEncode({ 'order': { @@ -109,30 +93,24 @@ class MostroService { }, }); final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.privateKey); + content, Config.mostroPubKey, session.masterKey.private); await _nostrService.publishEvent(event); return session; } Future publishOrder(MostroMessage order) async { - final session = await _secureStorageManager.newSession(); + final session = await _sessionManager.newSession(); final content = jsonEncode(order.toJson()); final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.privateKey); + content, Config.mostroPubKey, session.masterKey.private); await _nostrService.publishEvent(event); return session; } Future cancelOrder(String orderId) async { - final session = _sessions[orderId]; - - if (session == null) { - throw Exception('Session not found for order ID: $orderId'); - } - final content = jsonEncode({ 'order': { 'version': Config.mostroVersion, @@ -141,18 +119,18 @@ class MostroService { 'content': null, }, }); - final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.privateKey); - await _nostrService.publishEvent(event); - } - - Future sendFiatSent(String orderId) async { - final session = await _secureStorageManager.loadSession(orderId); - if (session == null) { - throw Exception('Session not found for order ID: $orderId'); + try { + final session = _sessionManager.getSessionByOrderId(orderId); + final event = await _nostrService.createNIP59Event( + content, Config.mostroPubKey, session.masterKey.private); + await _nostrService.publishEvent(event); + } catch (e) { + // catch and throw! } + } + Future sendFiatSent(String orderId) async { final content = jsonEncode({ 'order': { 'version': Config.mostroVersion, @@ -161,18 +139,18 @@ class MostroService { 'content': null, }, }); - 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'); + try { + final session = _sessionManager.getSessionByOrderId(orderId); + final event = await _nostrService.createNIP59Event( + content, Config.mostroPubKey, session.masterKey.private); + await _nostrService.publishEvent(event); + } catch (e) { + // catch and throw and log and stuff } + } + Future releaseOrder(String orderId) async { final content = jsonEncode({ 'order': { 'version': Config.mostroVersion, @@ -181,8 +159,13 @@ class MostroService { 'content': null, }, }); - final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.privateKey); - await _nostrService.publishEvent(event); + try { + final session = _sessionManager.getSessionByOrderId(orderId); + final event = await _nostrService.createNIP59Event( + content, Config.mostroPubKey, session.masterKey.private); + await _nostrService.publishEvent(event); + } catch (e) { + // catch and throw and log and stuff + } } } diff --git a/lib/shared/notifiers/app_settings_controller.dart b/lib/shared/notifiers/app_settings_controller.dart index 2764e39a..4ab858a2 100644 --- a/lib/shared/notifiers/app_settings_controller.dart +++ b/lib/shared/notifiers/app_settings_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/app/app_settings.dart'; +import 'package:mostro_mobile/constants/storage_keys.dart'; import 'package:mostro_mobile/shared/providers/storage_providers.dart'; class AppSettingsController extends StateNotifier { @@ -10,7 +11,9 @@ class AppSettingsController extends StateNotifier { Future loadSettings() async { final prefs = ref.read(sharedPreferencesProvider); - final fullPrivacyMode = await prefs.getBool('full_privacy_mode') ?? true; + final fullPrivacyMode = + await prefs.getBool(SharedPreferencesKeys.fullPrivacy.toString()) ?? + true; state = state.copyWith(fullPrivacyMode: fullPrivacyMode); } diff --git a/lib/shared/providers/session_manager_provider.dart b/lib/shared/providers/session_manager_provider.dart index 36556aae..95b94cbd 100644 --- a/lib/shared/providers/session_manager_provider.dart +++ b/lib/shared/providers/session_manager_provider.dart @@ -1,7 +1,10 @@ - import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/repositories/session_manager.dart'; +import 'package:mostro_mobile/shared/providers/key_manager_provider.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; final sessionManagerProvider = Provider((ref) { - return SessionManager(); + final keyManager = ref.read(keyManagerProvider); + final secureStorage = ref.read(secureStorageProvider); + return SessionManager(keyManager, secureStorage); }); diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 5b686d29..6b653f34 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -2,8 +2,6 @@ import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; -import 'package:bip32_bip44/dart_bip32_bip44.dart' as bip32_bip44; -import 'package:bip39/bip39.dart' as bip39; import 'package:elliptic/elliptic.dart'; import 'package:nip44/nip44.dart'; @@ -304,21 +302,4 @@ class NostrUtils { } } - static String getPrivateKeyFromMnemonic(String mnemonic, int index) { - final seed = bip39.mnemonicToSeedHex(mnemonic); - final chain = bip32_bip44.Chain.seed(seed); - - final key = chain.forPath("m/44'/1237'/38383'/0/0") - as bip32_bip44.ExtendedPrivateKey; - final childKey = bip32_bip44.deriveExtendedPrivateChildKey(key, index); - return (childKey.key != null) ? childKey.key!.toRadixString(16) : ''; - } - - static bool isMnemonicValid(String text) { - return bip39.validateMnemonic(text); - } - - static String getMnemonic() { - return bip39.generateMnemonic(); - } } From 90b0c06a2055f28359a39ab55b878532f6a03d33 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sun, 22 Dec 2024 20:59:43 -0800 Subject: [PATCH 006/149] Added separate wrap, seal and rumor functions --- lib/constants/storage_keys.dart | 3 +- lib/data/repositories/session_manager.dart | 20 ++++++-- lib/shared/utils/nostr_utils.dart | 53 +++++++++++++++++++++- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/lib/constants/storage_keys.dart b/lib/constants/storage_keys.dart index fe1b1bd6..028b51a2 100644 --- a/lib/constants/storage_keys.dart +++ b/lib/constants/storage_keys.dart @@ -26,7 +26,8 @@ enum SharedPreferencesKeys { enum SecureStorageKeys { masterKey('master_key'), - menemoic('mnemonic'); + menemoic('mnemonic'), + sessionKey('session-'); final String value; diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 3e3e1af1..6165145a 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mostro_mobile/constants/storage_keys.dart'; import 'package:mostro_mobile/services/key_manager.dart'; import 'package:mostro_mobile/data/models/session.dart'; @@ -18,6 +19,17 @@ class SessionManager { _initializeCleanup(); } + Future init() async { + final allKeys = await _flutterSecureStorage.readAll(); + + for (var e in allKeys.entries) { + if (e.key.startsWith(SecureStorageKeys.sessionKey.value)) { + final session = Session.fromJson(jsonDecode(e.value)); + _sessions[session.keyIndex] = session; + } + } + } + Future newSession({String? orderId}) async { final keys = await _keyManager.getMasterKey(); final keyIndex = await _keyManager.getCurrentKeyIndex(); @@ -38,7 +50,8 @@ class SessionManager { Future saveSession(Session session) async { String sessionJson = jsonEncode(session.toJson()); await _flutterSecureStorage.write( - key: session.keyIndex.toString(), value: sessionJson); + key: '${SecureStorageKeys.sessionKey}${session.keyIndex}', + value: sessionJson); } Future getSession(int sessionId) async { @@ -53,8 +66,7 @@ class SessionManager { } Future loadSession(int sessionId) async { - String? sessionJson = - await _flutterSecureStorage.read(key: sessionId.toString()); + String? sessionJson = await _flutterSecureStorage.read(key: '${SecureStorageKeys.sessionKey}$sessionId'); if (sessionJson != null) { return Session.fromJson(jsonDecode(sessionJson)); } @@ -63,7 +75,7 @@ class SessionManager { Future deleteSession(int sessionId) async { _sessions.remove(sessionId); - await _flutterSecureStorage.delete(key: sessionId.toString()); + await _flutterSecureStorage.delete(key: '${SecureStorageKeys.sessionKey}$sessionId'); } Future clearExpiredSessions() async { diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 6b653f34..08fee8ec 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -144,6 +144,58 @@ class NostrUtils { return now.subtract(Duration(seconds: randomSeconds)); } + static Future createRumor(String content, String recipientPubKey, + NostrKeyPairs senderPrivateKey) async { + final rumorEvent = NostrEvent.fromPartialData( + kind: 1, + keyPairs: senderPrivateKey, + content: content, + createdAt: DateTime.now(), + tags: [ + ["p", recipientPubKey] + ], + ); + + try { + return await _encryptNIP44(jsonEncode(rumorEvent.toMap()), + senderPrivateKey.private, recipientPubKey); + } catch (e) { + throw Exception('Failed to encrypt content: $e'); + } + } + + static Future createSeal(NostrKeyPairs senderKeyPair, + String recipientPubKey, String encryptedContent) async { + final sealEvent = NostrEvent.fromPartialData( + kind: 13, + keyPairs: senderKeyPair, + content: encryptedContent, + createdAt: randomNow(), + ); + + final wrapperKeyPair = generateKeyPair(); + + final pk = wrapperKeyPair.private; + + return await _encryptNIP44( + jsonEncode(sealEvent.toMap()), pk, recipientPubKey); + } + + static Future createWrap(NostrKeyPairs wrapperKeyPair, + String sealedContent, String recipientPubKey) async { + final wrapEvent = NostrEvent.fromPartialData( + kind: 1059, + content: sealedContent, + keyPairs: wrapperKeyPair, + tags: [ + ["p", recipientPubKey] + ], + createdAt: DateTime.now(), + ); + + return wrapEvent; + } + /// 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 @@ -301,5 +353,4 @@ class NostrUtils { throw Exception('Decryption failed: $e'); } } - } From d60899dafd38fbe905a6f5fe2c6708ed9d1a9e23 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sun, 22 Dec 2024 22:43:01 -0800 Subject: [PATCH 007/149] master key and trade key integration --- lib/data/models/mostro_message.dart | 14 ++- lib/services/mostro_service.dart | 156 +++++++++++----------------- lib/services/nostr_service.dart | 31 +++++- lib/shared/utils/nostr_utils.dart | 63 +++-------- 4 files changed, 109 insertions(+), 155 deletions(-) diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 470b842f..85892c1d 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -7,13 +7,15 @@ import 'package:mostro_mobile/data/models/payload.dart'; class MostroMessage { String? requestId; final Action action; + int? tradeIndex; T? _payload; - MostroMessage({required this.action, this.requestId, T? payload}) + MostroMessage( + {required this.action, this.requestId, T? payload, this.tradeIndex}) : _payload = payload; Map toJson() { - return { + final jMap = { 'order': { 'version': Config.mostroVersion, 'id': requestId, @@ -21,6 +23,10 @@ class MostroMessage { 'content': _payload?.toJson(), }, }; + if (tradeIndex != null) { + jMap['order']?['trade_index'] = tradeIndex; + } + return jMap; } factory MostroMessage.deserialized(String data) { @@ -39,10 +45,14 @@ class MostroMessage { ? Payload.fromJson(event['order']['content']) as T : null; + final tradeIndex = + order['trade_index'] != null ? int.parse(order['trade_index']) : null; + return MostroMessage( action: action, requestId: order['id'], payload: content, + tradeIndex: tradeIndex, ); } catch (e) { throw FormatException('Failed to deserialize MostroMessage: $e'); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 3f684945..a5bf1e82 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,5 +1,5 @@ import 'dart:convert'; -import 'package:dart_nostr/nostr/model/request/filter.dart'; +import 'package:dart_nostr/dart_nostr.dart'; import 'package:mostro_mobile/app/config.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; @@ -14,10 +14,10 @@ class MostroService { MostroService(this._nostrService, this._sessionManager); Stream subscribe(Session session) { - final filter = NostrFilter(p: [session.masterKey.public]); + final filter = NostrFilter(p: [session.tradeKey.public]); return _nostrService.subscribeToEvents(filter).asyncMap((event) async { final decryptedEvent = await _nostrService.decryptNIP59Event( - event, session.masterKey.private); + event, session.tradeKey.private); final msg = MostroMessage.deserialized(decryptedEvent.content!); if (session.orderId == null && msg.requestId != null) { session.orderId = msg.requestId; @@ -29,8 +29,8 @@ class MostroService { Future takeSellOrder( String orderId, int? amount, String? lnAddress) async { - final session = await _sessionManager.newSession(orderId: orderId); + final session = await _sessionManager.newSession(orderId: orderId); final order = lnAddress != null ? { 'payment_request': [null, lnAddress, amount] @@ -39,133 +39,95 @@ class MostroService { ? {'amount': amount} : null; - final content = jsonEncode({ - 'order': { - 'version': Config.mostroVersion, - 'id': orderId, - 'action': Action.takeSell.value, - 'content': order, - }, - }); - - final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.masterKey.private); - await _nostrService.publishEvent(event); + final content = newMessage(Action.takeSell, orderId, content: order); + await sendMessage(orderId, Config.mostroPubKey, content); return session; } Future sendInvoice(String orderId, String invoice) async { - final content = { - 'order': { - 'version': Config.mostroVersion, - 'id': orderId, - 'action': Action.addInvoice.value, - 'content': { - 'payment_request': [ - null, - invoice, - null, - ] - }, - }, - }; - - try { - final session = _sessionManager.getSessionByOrderId(orderId); - final event = await _nostrService.createNIP59Event( - jsonEncode(content), Config.mostroPubKey, session.masterKey.private); - - await _nostrService.publishEvent(event); - } catch (e) { - // check and log error kinds - } + final content = newMessage(Action.addInvoice, orderId, content: { + 'payment_request': [ + null, + invoice, + null, + ] + }); + await sendMessage(orderId, Config.mostroPubKey, content); } Future takeBuyOrder(String orderId, int? amount) async { final session = await _sessionManager.newSession(orderId: orderId); - - final content = jsonEncode({ - 'order': { - 'version': Config.mostroVersion, - 'id': orderId, - 'action': Action.takeBuy.value, - 'content': amount != null ? {'amount': amount} : null, - }, - }); - final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.masterKey.private); - await _nostrService.publishEvent(event); + final amt = amount != null ? {'amount': amount} : null; + final content = newMessage(Action.takeBuy, orderId, content: amt); + await sendMessage(orderId, Config.mostroPubKey, content); return session; } Future publishOrder(MostroMessage order) async { final session = await _sessionManager.newSession(); - final content = jsonEncode(order.toJson()); - - final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.masterKey.private); - + final event = await createNIP59Event(content, Config.mostroPubKey, session); await _nostrService.publishEvent(event); return session; } Future cancelOrder(String orderId) async { - final content = jsonEncode({ - 'order': { - 'version': Config.mostroVersion, - 'id': orderId, - 'action': Action.cancel.value, - 'content': null, - }, - }); - - try { - final session = _sessionManager.getSessionByOrderId(orderId); - final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.masterKey.private); - await _nostrService.publishEvent(event); - } catch (e) { - // catch and throw! - } + final content = newMessage(Action.cancel, orderId); + await sendMessage(orderId, Config.mostroPubKey, content); } Future sendFiatSent(String orderId) async { - final content = jsonEncode({ - 'order': { - 'version': Config.mostroVersion, - 'id': orderId, - 'action': Action.fiatSent.value, - 'content': null, - }, - }); - - try { - final session = _sessionManager.getSessionByOrderId(orderId); - final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.masterKey.private); - await _nostrService.publishEvent(event); - } catch (e) { - // catch and throw and log and stuff - } + final content = newMessage(Action.fiatSent, orderId); + await sendMessage(orderId, Config.mostroPubKey, content); } Future releaseOrder(String orderId) async { - final content = jsonEncode({ + final content = newMessage(Action.release, orderId); + await sendMessage(orderId, Config.mostroPubKey, content); + } + + Map newMessage(Action actionType, String orderId, + {Object? content}) { + return { 'order': { 'version': Config.mostroVersion, 'id': orderId, - 'action': Action.release.value, - 'content': null, + 'action': actionType.value, + 'content': content, }, - }); + }; + } + + Future sendMessage(String orderId, String receiverPubkey, + Map content) async { try { final session = _sessionManager.getSessionByOrderId(orderId); - final event = await _nostrService.createNIP59Event( - content, Config.mostroPubKey, session.masterKey.private); + if (session.fullPrivacy) { + content['order']?['trade_index'] = session.tradeKey; + } + final event = + await createNIP59Event(jsonEncode(content), receiverPubkey, session); await _nostrService.publishEvent(event); } catch (e) { // catch and throw and log and stuff } } + + Future createNIP59Event( + String content, String recipientPubKey, Session session) async { + final encryptedContent = await _nostrService.createRumor( + content, recipientPubKey, session.tradeKey); + + final wrapperKeyPair = await _nostrService.generateKeyPair(); + + final keySet = session.fullPrivacy ? session.tradeKey : session.masterKey; + + String sealedContent = await _nostrService.createSeal( + keySet, wrapperKeyPair.private, recipientPubKey, encryptedContent); + + final wrapEvent = await _nostrService.createWrap( + wrapperKeyPair, sealedContent, recipientPubKey); + + return wrapEvent; + } } diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 5e55415e..894360ff 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -21,8 +21,7 @@ class NostrService { await _nostr.relaysService.init( relaysUrl: Config.nostrRelays, connectionTimeout: Config.nostrConnectionTimeout, - onRelayListening: (relay, url, - channel) { + onRelayListening: (relay, url, channel) { _logger.info('Connected to relay: $url'); }, onRelayConnectionError: (relay, error, channel) { @@ -76,11 +75,15 @@ class NostrService { Future generateKeyPair() async { final keyPair = NostrUtils.generateKeyPair(); - await AuthUtils.savePrivateKeyAndPin( - keyPair.private, ''); // Consider adding a password parameter + //await AuthUtils.savePrivateKeyAndPin( + // keyPair.private, ''); // Consider adding a password parameter return keyPair; } + NostrKeyPairs generateKeyPairFromPrivateKey(String privateKey) { + return NostrUtils.generateKeyPairFromPrivateKey(privateKey); + } + String getMostroPubKey() { return Config.mostroPubKey; } @@ -95,11 +98,29 @@ class NostrService { content, recipientPubKey, senderPrivateKey); } - Future decryptNIP59Event(NostrEvent event, String privateKey) async { + Future decryptNIP59Event( + NostrEvent event, String privateKey) async { if (!_isInitialized) { throw Exception('Nostr is not initialized. Call init() first.'); } return NostrUtils.decryptNIP59Event(event, privateKey); } + + Future createRumor(String content, String recipientPubKey, + NostrKeyPairs senderPrivateKey) async { + return NostrUtils.createRumor(content, recipientPubKey, senderPrivateKey); + } + + Future createSeal(NostrKeyPairs senderKeyPair, String wrapperKey, + String recipientPubKey, String encryptedContent) async { + return NostrUtils.createSeal( + senderKeyPair, wrapperKey, recipientPubKey, encryptedContent); + } + + Future createWrap(NostrKeyPairs wrapperKeyPair, + String sealedContent, String recipientPubKey) async { + return NostrUtils.createWrap( + wrapperKeyPair, sealedContent, recipientPubKey); + } } diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 08fee8ec..1bf4c9dd 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -164,8 +164,11 @@ class NostrUtils { } } - static Future createSeal(NostrKeyPairs senderKeyPair, - String recipientPubKey, String encryptedContent) async { + static Future createSeal( + NostrKeyPairs senderKeyPair, + String wrapperKey, + String recipientPubKey, + String encryptedContent) async { final sealEvent = NostrEvent.fromPartialData( kind: 13, keyPairs: senderKeyPair, @@ -173,12 +176,8 @@ class NostrUtils { createdAt: randomNow(), ); - final wrapperKeyPair = generateKeyPair(); - - final pk = wrapperKeyPair.private; - return await _encryptNIP44( - jsonEncode(sealEvent.toMap()), pk, recipientPubKey); + jsonEncode(sealEvent.toMap()), wrapperKey, recipientPubKey); } static Future createWrap(NostrKeyPairs wrapperKeyPair, @@ -213,54 +212,16 @@ class NostrUtils { final senderKeyPair = generateKeyPairFromPrivateKey(senderPrivateKey); - final createdAt = DateTime.now(); - final rumorEvent = NostrEvent.fromPartialData( - kind: 1, - keyPairs: senderKeyPair, - content: content, - createdAt: createdAt, - tags: [ - ["p", recipientPubKey] - ], - ); - - 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(), - ); + String encryptedContent = + await createRumor(content, recipientPubKey, senderKeyPair); final wrapperKeyPair = generateKeyPair(); - 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'); - } + String sealedContent = + await createSeal(senderKeyPair, wrapperKeyPair.private, recipientPubKey, encryptedContent); - final wrapEvent = NostrEvent.fromPartialData( - kind: 1059, - content: sealedContent, - keyPairs: wrapperKeyPair, - tags: [ - ["p", recipientPubKey] - ], - createdAt: createdAt, - ); + final wrapEvent = + await createWrap(wrapperKeyPair, sealedContent, recipientPubKey); return wrapEvent; } From 2d53182bd7559093df146173f3683a7257c7eca2 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Mon, 23 Dec 2024 00:27:00 -0800 Subject: [PATCH 008/149] minor edits --- lib/constants/storage_keys.dart | 2 +- lib/data/repositories/mostro_repository.dart | 14 +++++--------- lib/notifiers/mostro_orders_notifier.dart | 18 ------------------ lib/services/key_manager.dart | 10 ++++++---- lib/services/key_manager_errors.dart | 15 +++++++++++++++ lib/services/mostro_service.dart | 12 +++++++++--- 6 files changed, 36 insertions(+), 35 deletions(-) delete mode 100644 lib/notifiers/mostro_orders_notifier.dart create mode 100644 lib/services/key_manager_errors.dart diff --git a/lib/constants/storage_keys.dart b/lib/constants/storage_keys.dart index 028b51a2..f46d6e20 100644 --- a/lib/constants/storage_keys.dart +++ b/lib/constants/storage_keys.dart @@ -26,7 +26,7 @@ enum SharedPreferencesKeys { enum SecureStorageKeys { masterKey('master_key'), - menemoic('mnemonic'), + mnemonic('mnemonic'), sessionKey('session-'); final String value; diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index 88e54188..ff7b8c55 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; @@ -9,21 +8,19 @@ class MostroRepository implements OrderRepository { final MostroService _mostroService; final Map _messages = {}; - final Map> _subscriptions = {}; - - final StreamController> _streamController = - StreamController>.broadcast(); + final Map> _subscriptions = {}; MostroRepository(this._mostroService); - Stream> get ordersStream => _streamController.stream; - MostroMessage? getOrderById(String orderId) => _messages[orderId]; Stream _subscribe(Session session) { - return _mostroService.subscribe(session)..listen((m) { + final stream = _mostroService.subscribe(session); + final subscription = stream.listen((m) { _messages[m.requestId!] = m; }); + _subscriptions[session.keyIndex] = subscription; + return stream; } Future> takeSellOrder( @@ -58,6 +55,5 @@ class MostroRepository implements OrderRepository { subscription.cancel(); } _subscriptions.clear(); - _streamController.close(); } } diff --git a/lib/notifiers/mostro_orders_notifier.dart b/lib/notifiers/mostro_orders_notifier.dart deleted file mode 100644 index 32ee9e57..00000000 --- a/lib/notifiers/mostro_orders_notifier.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; - -class MostroOrdersNotifier extends StateNotifier> { - final MostroRepository _repository; - - MostroOrdersNotifier(this._repository) : super([]) { - _repository.ordersStream.listen((orders) { - state = orders; - }); - } - - Future cleanupExpiredOrders(DateTime now) async { - //_repository.cleanupExpiredOrders(now); - state = await _repository.ordersStream.first; - } -} diff --git a/lib/services/key_manager.dart b/lib/services/key_manager.dart index 0eea749e..15145509 100644 --- a/lib/services/key_manager.dart +++ b/lib/services/key_manager.dart @@ -3,6 +3,7 @@ import 'package:bip39/bip39.dart' as bip39; import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mostro_mobile/constants/storage_keys.dart'; +import 'package:mostro_mobile/services/key_manager_errors.dart'; import 'package:shared_preferences/shared_preferences.dart'; class KeyManager { @@ -45,9 +46,10 @@ class KeyManager { final mnemonic = bip39.generateMnemonic(); final key = getPrivateKeyFromMnemonic(mnemonic); await secureStorage.write( - key: SecureStorageKeys.menemoic.toString(), value: mnemonic); + key: SecureStorageKeys.mnemonic.toString(), value: mnemonic); await secureStorage.write( key: SecureStorageKeys.masterKey.toString(), value: key); + await sharedPreferences.setInt(SharedPreferencesKeys.keyIndex.value, 1); } /// Derives a new trade key based on the current key index. @@ -57,7 +59,7 @@ class KeyManager { 0; final masterKey = await getMasterKey(); if (masterKey == null) { - throw Exception('Master key not found.'); + throw MasterKeyNotFoundException('Master key not found.'); } final chain = Chain.import(masterKey.private); final key = chain.forPath("m/44'/1237'/38383'/0/0") as ExtendedPrivateKey; @@ -73,7 +75,7 @@ class KeyManager { Future deriveTradeKeyFromIndex(int index) async { final masterKey = await getMasterKey(); if (masterKey == null) { - throw Exception('Master key not found.'); + throw MasterKeyNotFoundException('Master key not found.'); } final chain = Chain.import(masterKey.private); final key = chain.forPath("m/44'/1237'/38383'/0/0") as ExtendedPrivateKey; @@ -86,6 +88,6 @@ class KeyManager { Future getCurrentKeyIndex() async { return await sharedPreferences .getInt(SharedPreferencesKeys.keyIndex.toString()) ?? - 0; + 1; } } diff --git a/lib/services/key_manager_errors.dart b/lib/services/key_manager_errors.dart new file mode 100644 index 00000000..e0433f8c --- /dev/null +++ b/lib/services/key_manager_errors.dart @@ -0,0 +1,15 @@ +class MasterKeyNotFoundException implements Exception { + final String message; + MasterKeyNotFoundException(this.message); + + @override + String toString() => 'MasterKeyNotFoundException: $message'; +} + +class TradeKeyDerivationException implements Exception { + final String message; + TradeKeyDerivationException(this.message); + + @override + String toString() => 'TradeKeyDerivationException: $message'; +} \ No newline at end of file diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index a5bf1e82..0cba06b3 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:mostro_mobile/app/config.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; @@ -29,7 +30,6 @@ class MostroService { Future takeSellOrder( String orderId, int? amount, String? lnAddress) async { - final session = await _sessionManager.newSession(orderId: orderId); final order = lnAddress != null ? { @@ -102,11 +102,17 @@ class MostroService { Map content) async { try { final session = _sessionManager.getSessionByOrderId(orderId); + String finalContent; if (session.fullPrivacy) { - content['order']?['trade_index'] = session.tradeKey; + content['order']?['trade_index'] = session.keyIndex; + final sha256Digest = sha256.convert(utf8.encode(jsonEncode(content))); + final signature = session.tradeKey.sign(sha256Digest.toString()); + finalContent = jsonEncode([content, signature]); + } else { + finalContent = jsonEncode(content); } final event = - await createNIP59Event(jsonEncode(content), receiverPubkey, session); + await createNIP59Event(finalContent, receiverPubkey, session); await _nostrService.publishEvent(event); } catch (e) { // catch and throw and log and stuff From 0144d0e78aba553a778653f719083c1a25184b2b Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Mon, 23 Dec 2024 08:33:57 -0800 Subject: [PATCH 009/149] Minor fixes to session expiration --- lib/data/models/mostro_message.dart | 2 +- lib/data/repositories/session_manager.dart | 35 ++++++++++++++-------- lib/services/mostro_service.dart | 2 +- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 85892c1d..dd6e26de 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -1,5 +1,4 @@ import 'dart:convert'; - import 'package:mostro_mobile/app/config.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/payload.dart'; @@ -25,6 +24,7 @@ class MostroMessage { }; if (tradeIndex != null) { jMap['order']?['trade_index'] = tradeIndex; + jMap['order']?['content'] = [jMap['order']?['content']]; } return jMap; } diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 6165145a..27104897 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -61,12 +61,17 @@ class SessionManager { return await loadSession(sessionId); } - Session getSessionByOrderId(String orderId) { - return _sessions.values.firstWhere((s) => s.orderId == orderId); + Session? getSessionByOrderId(String orderId) { + try { + return _sessions.values.firstWhere((s) => s.orderId == orderId); + } on StateError { + return null; + } } Future loadSession(int sessionId) async { - String? sessionJson = await _flutterSecureStorage.read(key: '${SecureStorageKeys.sessionKey}$sessionId'); + String? sessionJson = await _flutterSecureStorage.read( + key: '${SecureStorageKeys.sessionKey}$sessionId'); if (sessionJson != null) { return Session.fromJson(jsonDecode(sessionJson)); } @@ -75,34 +80,38 @@ class SessionManager { Future deleteSession(int sessionId) async { _sessions.remove(sessionId); - await _flutterSecureStorage.delete(key: '${SecureStorageKeys.sessionKey}$sessionId'); + await _flutterSecureStorage.delete( + key: '${SecureStorageKeys.sessionKey}$sessionId'); } Future clearExpiredSessions() async { try { final now = DateTime.now(); final allKeys = await _flutterSecureStorage.readAll(); - int processedCount = 0; + final entries = allKeys.entries + .where((e) => e.key.startsWith(SecureStorageKeys.sessionKey.value)) + .toList(); - allKeys.forEach((key, value) async { - if (processedCount >= maxBatchSize) { - // Schedule remaining cleanup for next run - return; - } - final sessionJson = value; + int processedCount = 0; + for (final entry in entries) { + if (processedCount >= maxBatchSize) break; + final key = entry.key; + final value = entry.value; try { - final session = Session.fromJson(jsonDecode(sessionJson)); + final session = Session.fromJson(jsonDecode(value)); if (now.difference(session.startTime).inHours >= sessionExpirationHours) { await _flutterSecureStorage.delete(key: key); + _sessions.remove(session.keyIndex); processedCount++; } } catch (e) { print('Error processing session $key: $e'); await _flutterSecureStorage.delete(key: key); + _sessions.removeWhere((_, s) => 'session_${s.keyIndex}' == key); processedCount++; } - }); + } } catch (e) { print('Error during session cleanup: $e'); } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 0cba06b3..7c533cfe 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -103,7 +103,7 @@ class MostroService { try { final session = _sessionManager.getSessionByOrderId(orderId); String finalContent; - if (session.fullPrivacy) { + if (session!.fullPrivacy) { content['order']?['trade_index'] = session.keyIndex; final sha256Digest = sha256.convert(utf8.encode(jsonEncode(content))); final signature = session.tradeKey.sign(sha256Digest.toString()); From a27c70b4cd4817f7af36d9a7514d4d3fc7963020 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Tue, 24 Dec 2024 13:46:14 -0800 Subject: [PATCH 010/149] implementation of key manager features --- lib/app/config.dart | 5 +- .../models/enums}/storage_keys.dart | 0 lib/data/repositories/session_manager.dart | 6 +- lib/features/key_manager/key_derivator.dart | 48 ++++++++++ lib/features/key_manager/key_manager.dart | 73 +++++++++++++++ .../key_manager}/key_manager_errors.dart | 0 .../key_manager/key_manager_provider.dart | 14 +++ lib/features/key_manager/key_storage.dart | 33 +++++++ lib/services/key_manager.dart | 93 ------------------- lib/services/nostr_service.dart | 1 - .../notifiers/app_settings_controller.dart | 2 +- lib/shared/providers/init_provider.dart | 7 +- .../providers/key_manager_provider.dart | 9 -- .../providers/session_manager_provider.dart | 2 +- pubspec.lock | 18 +++- pubspec.yaml | 2 +- test/services/key_derivator_test.dart | 67 +++++++++++++ 17 files changed, 266 insertions(+), 114 deletions(-) rename lib/{constants => data/models/enums}/storage_keys.dart (100%) create mode 100644 lib/features/key_manager/key_derivator.dart create mode 100644 lib/features/key_manager/key_manager.dart rename lib/{services => features/key_manager}/key_manager_errors.dart (100%) create mode 100644 lib/features/key_manager/key_manager_provider.dart create mode 100644 lib/features/key_manager/key_storage.dart delete mode 100644 lib/services/key_manager.dart delete mode 100644 lib/shared/providers/key_manager_provider.dart create mode 100644 test/services/key_derivator_test.dart diff --git a/lib/app/config.dart b/lib/app/config.dart index acef260f..6e627f9d 100644 --- a/lib/app/config.dart +++ b/lib/app/config.dart @@ -3,8 +3,9 @@ import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ - 'ws://127.0.0.1:7000', - 'wss://relay.mostro.network', + //'ws://127.0.0.1:7000', + 'ws://192.168.1.144:7000' + //'wss://relay.mostro.network', //'ws://10.0.2.2:7000', //'wss://relay.damus.io', //'wss://relay.nostr.net', diff --git a/lib/constants/storage_keys.dart b/lib/data/models/enums/storage_keys.dart similarity index 100% rename from lib/constants/storage_keys.dart rename to lib/data/models/enums/storage_keys.dart diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 27104897..8fcdf49d 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:mostro_mobile/constants/storage_keys.dart'; -import 'package:mostro_mobile/services/key_manager.dart'; +import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager.dart'; import 'package:mostro_mobile/data/models/session.dart'; class SessionManager { @@ -36,7 +36,7 @@ class SessionManager { final tradeKey = await _keyManager.deriveTradeKey(); final session = Session( startTime: DateTime.now(), - masterKey: keys!, + masterKey: keys, keyIndex: keyIndex, tradeKey: tradeKey, fullPrivacy: false, diff --git a/lib/features/key_manager/key_derivator.dart b/lib/features/key_manager/key_derivator.dart new file mode 100644 index 00000000..dd42b392 --- /dev/null +++ b/lib/features/key_manager/key_derivator.dart @@ -0,0 +1,48 @@ +import 'package:bip39/bip39.dart' as bip39; +import 'package:bip32/bip32.dart' as bip32; +import 'package:convert/convert.dart'; +import 'package:dart_nostr/dart_nostr.dart'; + +/// A utility class for generating, validating, and deriving keys +/// according to NIP-06 +class KeyDerivator { + final String derivationPath; + + KeyDerivator(this.derivationPath); + + String generateMnemonic() { + return bip39.generateMnemonic(); + } + + bool isMnemonicValid(String mnemonic) { + return bip39.validateMnemonic(mnemonic); + } + + String masterPrivateKeyFromMnemonic(String mnemonic) { + final seedHex = bip39.mnemonicToSeed(mnemonic); + final node = bip32.BIP32.fromSeed(seedHex); + final child = node.derivePath('$derivationPath/0'); + return hex.encode(child.privateKey!); + } + + String extendedKeyFromMnemonic(String mnemonic) { + final seed = bip39.mnemonicToSeed(mnemonic); + final root = bip32.BIP32.fromSeed(seed); + return root.toBase58(); + } + + String derivePrivateKey(String extendedPrivateKey, int index) { + final root = bip32.BIP32.fromBase58(extendedPrivateKey); + final child = root.derivePath('$derivationPath/$index'); + if (child.privateKey == null) { + throw Exception( + "Derived child key has no private key. Possibly a neutered node?"); + } + return hex.encode(child.privateKey!); + } + + String privateToPublicKey(String privateKeyHex) { + final keyPairs = NostrKeyPairs(private: privateKeyHex); + return keyPairs.public; + } +} diff --git a/lib/features/key_manager/key_manager.dart b/lib/features/key_manager/key_manager.dart new file mode 100644 index 00000000..6d07ba22 --- /dev/null +++ b/lib/features/key_manager/key_manager.dart @@ -0,0 +1,73 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; +import 'package:mostro_mobile/features/key_manager/key_storage.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager_errors.dart'; + +class KeyManager { + final KeyStorage _storage; + final KeyDerivator _derivator; + + KeyManager(this._storage, this._derivator); + + Future hasMasterKey() async { + final masterKeyHex = await _storage.readMasterKey(); + return masterKeyHex != null; + } + + /// Generate a new mnemonic, derive the master key, and store both + Future generateAndStoreMasterKey() async { + final mnemonic = _derivator.generateMnemonic(); + final masterKeyHex = _derivator.masterPrivateKeyFromMnemonic(mnemonic); + + await _storage.storeMnemonic(mnemonic); + await _storage.storeMasterKey(masterKeyHex); + await _storage + .storeTradeKeyIndex(1); + } + + /// Retrieve the master key from storage, returning NostrKeyPairs + /// or throws a MasterKeyNotFoundException if not found + Future getMasterKey() async { + final masterKeyHex = await _storage.readMasterKey(); + if (masterKeyHex == null) { + throw MasterKeyNotFoundException('No master key found in secure storage'); + } + return NostrKeyPairs(private: masterKeyHex); + } + + /// Return the stored mnemonic, or null if none + Future getMnemonic() async { + return _storage.readMnemonic(); + } + + Future deriveTradeKey() async { + final masterKeyHex = await _storage.readMasterKey(); + if (masterKeyHex == null) { + throw MasterKeyNotFoundException('No master key found in secure storage'); + } + final currentIndex = await _storage.readTradeKeyIndex(); + + final tradePrivateHex = + _derivator.derivePrivateKey(masterKeyHex, currentIndex!); + + // increment index + await _storage.storeTradeKeyIndex(currentIndex + 1); + + return NostrKeyPairs(private: tradePrivateHex); + } + + /// Derive a trade key for a specific index + Future deriveTradeKeyFromIndex(int index) async { + final masterKeyHex = await _storage.readMasterKey(); + if (masterKeyHex == null) { + throw MasterKeyNotFoundException('No master key found in secure storage'); + } + final tradePrivateHex = _derivator.derivePrivateKey(masterKeyHex, index); + + return NostrKeyPairs(private: tradePrivateHex); + } + + Future getCurrentKeyIndex() async { + return await _storage.readTradeKeyIndex(); + } +} diff --git a/lib/services/key_manager_errors.dart b/lib/features/key_manager/key_manager_errors.dart similarity index 100% rename from lib/services/key_manager_errors.dart rename to lib/features/key_manager/key_manager_errors.dart diff --git a/lib/features/key_manager/key_manager_provider.dart b/lib/features/key_manager/key_manager_provider.dart new file mode 100644 index 00000000..c7b38601 --- /dev/null +++ b/lib/features/key_manager/key_manager_provider.dart @@ -0,0 +1,14 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager.dart'; +import 'package:mostro_mobile/features/key_manager/key_storage.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; + +final keyManagerProvider = Provider((ref) { + final secureStorage = ref.watch(secureStorageProvider); + final sharedPrefs = ref.watch(sharedPreferencesProvider); + final keyStorage = + KeyStorage(secureStorage: secureStorage, sharedPrefs: sharedPrefs); + final keyDerivator = KeyDerivator("m/44'/1237'/38383'/0"); + return KeyManager(keyStorage, keyDerivator); +}); diff --git a/lib/features/key_manager/key_storage.dart b/lib/features/key_manager/key_storage.dart new file mode 100644 index 00000000..a478bf53 --- /dev/null +++ b/lib/features/key_manager/key_storage.dart @@ -0,0 +1,33 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class KeyStorage { + final FlutterSecureStorage secureStorage; + final SharedPreferencesAsync sharedPrefs; + + KeyStorage({required this.secureStorage, required this.sharedPrefs}); + Future storeMasterKey(String masterKey) async { + await secureStorage.write(key: SecureStorageKeys.masterKey.value, value: masterKey); + } + + Future readMasterKey() async { + return secureStorage.read(key: SecureStorageKeys.masterKey.value); + } + + Future storeMnemonic(String mnemonic) async { + await secureStorage.write(key: SecureStorageKeys.mnemonic.value, value: mnemonic); + } + + Future readMnemonic() async { + return secureStorage.read(key: SecureStorageKeys.mnemonic.value); + } + + Future storeTradeKeyIndex(int index) async { + await sharedPrefs.setInt(SharedPreferencesKeys.keyIndex.value, index); + } + + Future readTradeKeyIndex() async { + return await sharedPrefs.getInt(SharedPreferencesKeys.keyIndex.value) ?? 1; + } +} diff --git a/lib/services/key_manager.dart b/lib/services/key_manager.dart deleted file mode 100644 index 15145509..00000000 --- a/lib/services/key_manager.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:bip32_bip44/dart_bip32_bip44.dart'; -import 'package:bip39/bip39.dart' as bip39; -import 'package:dart_nostr/dart_nostr.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:mostro_mobile/constants/storage_keys.dart'; -import 'package:mostro_mobile/services/key_manager_errors.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class KeyManager { - final FlutterSecureStorage secureStorage; - final SharedPreferencesAsync sharedPreferences; - - KeyManager({required this.secureStorage, required this.sharedPreferences}); - - static String getPrivateKeyFromMnemonic(String mnemonic) { - final seed = bip39.mnemonicToSeedHex(mnemonic); - final chain = Chain.seed(seed); - final key = chain.forPath("m/44'/1237'/38383'/0/0"); - return key.privateKeyHex(); - } - - static String generateMnemonic() { - return bip39.generateMnemonic(); - } - - static bool isMnemonicValid(String text) { - return bip39.validateMnemonic(text); - } - - Future hasMasterKey() async { - String? masterKey = - await secureStorage.read(key: SecureStorageKeys.masterKey.toString()); - return masterKey != null; - } - - Future getMasterKey() async { - String? masterKeyHex = - await secureStorage.read(key: SecureStorageKeys.masterKey.toString()); - if (masterKeyHex == null) { - return null; - } - return NostrKeyPairs(private: masterKeyHex); - } - - Future generateAndStoreMasterKey() async { - final mnemonic = bip39.generateMnemonic(); - final key = getPrivateKeyFromMnemonic(mnemonic); - await secureStorage.write( - key: SecureStorageKeys.mnemonic.toString(), value: mnemonic); - await secureStorage.write( - key: SecureStorageKeys.masterKey.toString(), value: key); - await sharedPreferences.setInt(SharedPreferencesKeys.keyIndex.value, 1); - } - - /// Derives a new trade key based on the current key index. - Future deriveTradeKey() async { - int currentIndex = await sharedPreferences - .getInt(SharedPreferencesKeys.keyIndex.toString()) ?? - 0; - final masterKey = await getMasterKey(); - if (masterKey == null) { - throw MasterKeyNotFoundException('Master key not found.'); - } - final chain = Chain.import(masterKey.private); - final key = chain.forPath("m/44'/1237'/38383'/0/0") as ExtendedPrivateKey; - final tradeKey = deriveExtendedPrivateChildKey(key, currentIndex); - - // Increment and save the key index - await sharedPreferences.setInt( - SharedPreferencesKeys.keyIndex.toString(), currentIndex + 1); - - return NostrKeyPairs(private: tradeKey.privateKeyHex()); - } - - Future deriveTradeKeyFromIndex(int index) async { - final masterKey = await getMasterKey(); - if (masterKey == null) { - throw MasterKeyNotFoundException('Master key not found.'); - } - final chain = Chain.import(masterKey.private); - final key = chain.forPath("m/44'/1237'/38383'/0/0") as ExtendedPrivateKey; - final tradeKey = deriveExtendedPrivateChildKey(key, index); - - return NostrKeyPairs(private: tradeKey.privateKeyHex()); - } - - /// Retrieves the current key index - Future getCurrentKeyIndex() async { - return await sharedPreferences - .getInt(SharedPreferencesKeys.keyIndex.toString()) ?? - 1; - } -} diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 894360ff..189337ab 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -1,7 +1,6 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:mostro_mobile/app/config.dart'; import 'package:logging/logging.dart'; -import 'package:mostro_mobile/shared/utils/auth_utils.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class NostrService { diff --git a/lib/shared/notifiers/app_settings_controller.dart b/lib/shared/notifiers/app_settings_controller.dart index 4ab858a2..50b1e129 100644 --- a/lib/shared/notifiers/app_settings_controller.dart +++ b/lib/shared/notifiers/app_settings_controller.dart @@ -1,6 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/app/app_settings.dart'; -import 'package:mostro_mobile/constants/storage_keys.dart'; +import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; import 'package:mostro_mobile/shared/providers/storage_providers.dart'; class AppSettingsController extends StateNotifier { diff --git a/lib/shared/providers/init_provider.dart b/lib/shared/providers/init_provider.dart index 825d36e2..7ae9ea37 100644 --- a/lib/shared/providers/init_provider.dart +++ b/lib/shared/providers/init_provider.dart @@ -1,10 +1,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/shared/providers/key_manager_provider.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; final appInitializerProvider = FutureProvider((ref) async { + final flutterSecureStorage = ref.read(secureStorageProvider); + flutterSecureStorage.deleteAll(); final keyManager = ref.read(keyManagerProvider); bool hasMaster = await keyManager.hasMasterKey(); if (!hasMaster) { await keyManager.generateAndStoreMasterKey(); } -}); \ No newline at end of file +}); diff --git a/lib/shared/providers/key_manager_provider.dart b/lib/shared/providers/key_manager_provider.dart deleted file mode 100644 index 339c974d..00000000 --- a/lib/shared/providers/key_manager_provider.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/services/key_manager.dart'; -import 'package:mostro_mobile/shared/providers/storage_providers.dart'; - -final keyManagerProvider = Provider((ref) { - final secureStorage = ref.watch(secureStorageProvider); - final sharedPrefs = ref.watch(sharedPreferencesProvider); - return KeyManager(secureStorage: secureStorage, sharedPreferences: sharedPrefs); -}); \ No newline at end of file diff --git a/lib/shared/providers/session_manager_provider.dart b/lib/shared/providers/session_manager_provider.dart index 95b94cbd..66a8b0b0 100644 --- a/lib/shared/providers/session_manager_provider.dart +++ b/lib/shared/providers/session_manager_provider.dart @@ -1,6 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/repositories/session_manager.dart'; -import 'package:mostro_mobile/shared/providers/key_manager_provider.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/shared/providers/storage_providers.dart'; final sessionManagerProvider = Provider((ref) { diff --git a/pubspec.lock b/pubspec.lock index ad9d9193..5d4fa4bc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -62,8 +62,16 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" - bip32_bip44: + bip32: dependency: "direct main" + description: + name: bip32 + sha256: "54787cd7a111e9d37394aabbf53d1fc5e2e0e0af2cd01c459147a97c0e3f8a97" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + bip32_bip44: + dependency: transitive description: name: bip32_bip44 sha256: "8e28e6bde00da1ed207f2c0a5361792375799196176742c0d36c71e89a5485d3" @@ -110,6 +118,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + bs58check: + dependency: transitive + description: + name: bs58check + sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9 + url: "https://pub.dev" + source: hosted + version: "1.0.2" build: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cdcc1693..3147253a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,11 +68,11 @@ dependencies: path: flutter_secure_storage ref: develop go_router: ^14.6.2 - bip32_bip44: ^1.0.0 bip39: ^1.0.6 flutter_hooks: ^0.20.5 hooks_riverpod: ^2.6.1 flutter_launcher_icons: ^0.14.2 + bip32: ^2.0.0 dev_dependencies: flutter_test: diff --git a/test/services/key_derivator_test.dart b/test/services/key_derivator_test.dart new file mode 100644 index 00000000..831b664a --- /dev/null +++ b/test/services/key_derivator_test.dart @@ -0,0 +1,67 @@ +import 'package:test/test.dart'; +import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; + +/// Test vectors from NIP-06 +/// https://github.com/nostr-protocol/nips/blob/master/06.md + +void main() { + group('KeyDerivator (NIP-06) Tests', () { + const derivationPath = "m/44'/1237'/0'/0"; + test('Test Vector 1: leader monkey parrot ring guide accident ...', () { + const mnemonic = + 'leader monkey parrot ring guide accident before fence cannon height naive bean'; + const expectedPrivateHex = + '7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a'; + const expectedPublicHex = + '17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917'; + final keyDerivator = KeyDerivator(derivationPath); + + expect(keyDerivator.isMnemonicValid(mnemonic), isTrue); + + final derivedMaster = keyDerivator.masterPrivateKeyFromMnemonic(mnemonic); + expect(derivedMaster, equals(expectedPrivateHex)); + + final computedPub = keyDerivator.privateToPublicKey(derivedMaster); + expect(computedPub, equals(expectedPublicHex)); + }); + + test('Test Vector 2: what bleak badge arrange retreat wolf trade ...', () { + const mnemonic = + 'what bleak badge arrange retreat wolf trade produce cricket blur garlic valid proud rude strong choose busy staff weather area salt hollow arm fade'; + const expectedPrivateHex = + 'c15d739894c81a2fcfd3a2df85a0d2c0dbc47a280d092799f144d73d7ae78add'; + const expectedPublicHex = + 'd41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573'; + final keyDerivator = KeyDerivator(derivationPath); + + expect(keyDerivator.isMnemonicValid(mnemonic), isTrue); + + final derivedMaster = keyDerivator.masterPrivateKeyFromMnemonic(mnemonic); + expect(derivedMaster, equals(expectedPrivateHex)); + + final computedPub = keyDerivator.privateToPublicKey(derivedMaster); + expect(computedPub, equals(expectedPublicHex)); + }); + + test('Random mnemonic is valid', () { + final keyDerivator = KeyDerivator(derivationPath); + final mnemonic = keyDerivator.generateMnemonic(); + expect(keyDerivator.isMnemonicValid(mnemonic), isTrue); + }); + + test('Derive child key from an example master key', () { + const mnemonic = + 'leader monkey parrot ring guide accident before fence cannon height naive bean'; + const expectedPrivateHex = + '7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a'; + final keyDerivator = KeyDerivator(derivationPath); + final masterKeyHex = keyDerivator.extendedKeyFromMnemonic(mnemonic); + final childKeyHex = keyDerivator.derivePrivateKey(masterKeyHex, 0); + + expect(childKeyHex.length, equals(64)); + expect(childKeyHex, equals(expectedPrivateHex)); + }); + }); + + group('Key derivation tests for Mostro', () {}); +} From c731b636d22cd90ca89d9a98bbd15d2ffe8f9167 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Tue, 24 Dec 2024 14:20:48 -0800 Subject: [PATCH 011/149] verified sending new orders with full privacy --- lib/features/key_manager/key_manager.dart | 7 ++++--- lib/services/mostro_service.dart | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/features/key_manager/key_manager.dart b/lib/features/key_manager/key_manager.dart index 6d07ba22..285c2629 100644 --- a/lib/features/key_manager/key_manager.dart +++ b/lib/features/key_manager/key_manager.dart @@ -17,7 +17,7 @@ class KeyManager { /// Generate a new mnemonic, derive the master key, and store both Future generateAndStoreMasterKey() async { final mnemonic = _derivator.generateMnemonic(); - final masterKeyHex = _derivator.masterPrivateKeyFromMnemonic(mnemonic); + final masterKeyHex = _derivator.extendedKeyFromMnemonic(mnemonic); await _storage.storeMnemonic(mnemonic); await _storage.storeMasterKey(masterKeyHex); @@ -32,7 +32,8 @@ class KeyManager { if (masterKeyHex == null) { throw MasterKeyNotFoundException('No master key found in secure storage'); } - return NostrKeyPairs(private: masterKeyHex); + final privKey = _derivator.derivePrivateKey(masterKeyHex, 0); + return NostrKeyPairs(private: privKey); } /// Return the stored mnemonic, or null if none @@ -48,7 +49,7 @@ class KeyManager { final currentIndex = await _storage.readTradeKeyIndex(); final tradePrivateHex = - _derivator.derivePrivateKey(masterKeyHex, currentIndex!); + _derivator.derivePrivateKey(masterKeyHex, currentIndex); // increment index await _storage.storeTradeKeyIndex(currentIndex + 1); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 7c533cfe..07434c12 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -126,7 +126,7 @@ class MostroService { final wrapperKeyPair = await _nostrService.generateKeyPair(); - final keySet = session.fullPrivacy ? session.tradeKey : session.masterKey; + final keySet = session.fullPrivacy ? session.masterKey : session.tradeKey; String sealedContent = await _nostrService.createSeal( keySet, wrapperKeyPair.private, recipientPubKey, encryptedContent); From 7d299cb532e0ff0a8025d88cb70b655bbccb421e Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 25 Dec 2024 00:12:17 -0800 Subject: [PATCH 012/149] message and session serialization and retrieval --- lib/data/models/enums/storage_keys.dart | 1 + lib/data/models/session.dart | 23 ++++++----- lib/data/repositories/mostro_repository.dart | 40 ++++++++++++++++++-- lib/data/repositories/session_manager.dart | 26 ++++++++----- lib/main.dart | 3 +- 5 files changed, 68 insertions(+), 25 deletions(-) diff --git a/lib/data/models/enums/storage_keys.dart b/lib/data/models/enums/storage_keys.dart index f46d6e20..e25d77c4 100644 --- a/lib/data/models/enums/storage_keys.dart +++ b/lib/data/models/enums/storage_keys.dart @@ -27,6 +27,7 @@ enum SharedPreferencesKeys { enum SecureStorageKeys { masterKey('master_key'), mnemonic('mnemonic'), + message('message-'), sessionKey('session-'); final String value; diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 8c417dcc..06002978 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -19,25 +19,24 @@ class Session { required this.fullPrivacy, this.orderId}); + // We don't store the keys in the session files Map toJson() => { - 'startTime': startTime.toIso8601String(), - 'masterKey': masterKey.private, - 'tradeKey': tradeKey.private, - 'eventId': orderId, - 'keyIndex': keyIndex, - 'fullPrivacy': fullPrivacy, + 'start_time': startTime.toIso8601String(), + 'event_id': orderId, + 'key_index': keyIndex, + 'full_privacy': fullPrivacy, }; factory Session.fromJson(Map json) { return Session( - startTime: DateTime.parse(json['startTime']), + startTime: DateTime.parse(json['start_time']), masterKey: NostrKeyPairs( - private: json['masterKey'], + private: json['master_key'], ), - orderId: json['eventId'], - keyIndex: int.parse(json['keyIndex']), - tradeKey: NostrKeyPairs(private: json['tradeKey']), - fullPrivacy: json['fullPrivacy'], + orderId: json['event_id'], + keyIndex: int.parse(json['key_index']), + tradeKey: NostrKeyPairs(private: json['trade_key']), + fullPrivacy: json['full_privacy'], ); } } diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index ff7b8c55..dc43a442 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -1,4 +1,7 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; @@ -6,18 +9,20 @@ import 'package:mostro_mobile/services/mostro_service.dart'; class MostroRepository implements OrderRepository { final MostroService _mostroService; + final FlutterSecureStorage _flutterSecureStorage; final Map _messages = {}; final Map> _subscriptions = {}; - MostroRepository(this._mostroService); + MostroRepository(this._mostroService, this._flutterSecureStorage); MostroMessage? getOrderById(String orderId) => _messages[orderId]; Stream _subscribe(Session session) { final stream = _mostroService.subscribe(session); - final subscription = stream.listen((m) { + final subscription = stream.listen((m) async { _messages[m.requestId!] = m; + await saveMessage(m); }); _subscriptions[session.keyIndex] = subscription; return stream; @@ -45,10 +50,39 @@ class MostroRepository implements OrderRepository { return _subscribe(session); } - void cancelOrder(String orderId) async { + Future cancelOrder(String orderId) async { await _mostroService.cancelOrder(orderId); } + Future saveMessages() async { + for (var m in _messages.values.toList()) { + await _flutterSecureStorage.write( + key: '${SecureStorageKeys.message}-${m.requestId}', + value: jsonEncode(m.toJson())); + } + } + + Future saveMessage(MostroMessage message) async { + await _flutterSecureStorage.write( + key: '${SecureStorageKeys.message}-${message.requestId}', + value: jsonEncode(message.toJson())); + } + + Future deleteMessage(String messageId) async { + await _flutterSecureStorage.delete( + key: '${SecureStorageKeys.message}-$messageId'); + } + + Future loadMessages() async { + final allKeys = await _flutterSecureStorage.readAll(); + for (var e in allKeys.entries) { + if (e.key.startsWith(SecureStorageKeys.message.value)) { + final message = MostroMessage.deserialized(e.value); + _messages[message.requestId!] = message; + } + } + } + @override void dispose() { for (final subscription in _subscriptions.values) { diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 8fcdf49d..8195a5a4 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -9,22 +9,21 @@ class SessionManager { final KeyManager _keyManager; final FlutterSecureStorage _flutterSecureStorage; final Map _sessions = {}; - Timer? _cleanupTimer; final int sessionExpirationHours = 48; static const cleanupIntervalMinutes = 30; static const maxBatchSize = 100; SessionManager(this._keyManager, this._flutterSecureStorage) { + _init(); _initializeCleanup(); } - Future init() async { + Future _init() async { final allKeys = await _flutterSecureStorage.readAll(); - for (var e in allKeys.entries) { if (e.key.startsWith(SecureStorageKeys.sessionKey.value)) { - final session = Session.fromJson(jsonDecode(e.value)); + final session = await sessionJsonDecode(e.value); _sessions[session.keyIndex] = session; } } @@ -58,7 +57,7 @@ class SessionManager { if (_sessions.containsKey(sessionId)) { return _sessions[sessionId]; } - return await loadSession(sessionId); + return await loadSession('${SecureStorageKeys.sessionKey}$sessionId'); } Session? getSessionByOrderId(String orderId) { @@ -69,15 +68,22 @@ class SessionManager { } } - Future loadSession(int sessionId) async { - String? sessionJson = await _flutterSecureStorage.read( - key: '${SecureStorageKeys.sessionKey}$sessionId'); + Future loadSession(String sessionId) async { + String? sessionJson = await _flutterSecureStorage.read(key: sessionId); if (sessionJson != null) { - return Session.fromJson(jsonDecode(sessionJson)); + return sessionJsonDecode(sessionJson); } return null; } + Future sessionJsonDecode(String sessionJson) async { + final session = jsonDecode(sessionJson) as Map; + int index = session['key_index']; + session['master_key'] = await _keyManager.getMasterKey(); + session['trade_key'] = await _keyManager.deriveTradeKeyFromIndex(index); + return Session.fromJson(session); + } + Future deleteSession(int sessionId) async { _sessions.remove(sessionId); await _flutterSecureStorage.delete( @@ -128,4 +134,6 @@ class SessionManager { void dispose() { _cleanupTimer?.cancel(); } + + List get sessions => _sessions.values.toList(); } diff --git a/lib/main.dart b/lib/main.dart index 3fce96d1..888086e6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mostro_mobile/app/app.dart'; +import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; @@ -17,7 +18,7 @@ void main() async { final biometricsHelper = BiometricsHelper(); final sharedPreferences = SharedPreferencesAsync(); final secureStorage = const FlutterSecureStorage(); - + runApp( ProviderScope( overrides: [ From 3290c1fadfee8a825c2bb77e20294d06b30d4eec Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 25 Dec 2024 00:36:27 -0800 Subject: [PATCH 013/149] updated mostro message to handle cant-do --- lib/data/models/mostro_message.dart | 4 +++- lib/shared/providers/mostro_service_provider.dart | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index dd6e26de..2b3b7e59 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -35,7 +35,9 @@ class MostroMessage { final event = decoded as Map; final order = event['order'] != null ? event['order'] as Map - : throw FormatException('Missing order object'); + : event['cant-do'] != null + ? event['cant-do'] as Map + : throw FormatException('Missing order object'); final action = order['action'] != null ? Action.fromString(order['action']) diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index 94399c0d..24fc53ad 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -3,6 +3,7 @@ import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; final mostroServiceProvider = Provider((ref) { final sessionStorage = ref.watch(sessionManagerProvider); @@ -12,5 +13,6 @@ final mostroServiceProvider = Provider((ref) { final mostroRepositoryProvider = Provider((ref) { final mostroService = ref.watch(mostroServiceProvider); - return MostroRepository(mostroService); + final secureStorage = ref.watch(secureStorageProvider); + return MostroRepository(mostroService, secureStorage); }); From b3615e0bedbb20b1ce1b8a290761eca46c6c5f15 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 25 Dec 2024 11:48:16 -0800 Subject: [PATCH 014/149] Implementation of order persistence between restarts --- lib/data/repositories/mostro_repository.dart | 47 ++++++--- lib/data/repositories/session_manager.dart | 95 +++++++++++-------- .../order/notfiers/order_notifier.dart | 91 ++++++++++++++++++ .../providers/order_notifier_provider.dart | 15 +++ lib/services/mostro_service.dart | 5 + lib/shared/providers/init_provider.dart | 12 ++- 6 files changed, 205 insertions(+), 60 deletions(-) create mode 100644 lib/features/order/notfiers/order_notifier.dart create mode 100644 lib/features/order/providers/order_notifier_provider.dart diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index dc43a442..81d377f1 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -9,25 +9,38 @@ import 'package:mostro_mobile/services/mostro_service.dart'; class MostroRepository implements OrderRepository { final MostroService _mostroService; - final FlutterSecureStorage _flutterSecureStorage; + final FlutterSecureStorage _secureStorage; final Map _messages = {}; final Map> _subscriptions = {}; - MostroRepository(this._mostroService, this._flutterSecureStorage); + MostroRepository(this._mostroService, this._secureStorage); MostroMessage? getOrderById(String orderId) => _messages[orderId]; + List get allMessages => _messages.values.toList(); Stream _subscribe(Session session) { final stream = _mostroService.subscribe(session); - final subscription = stream.listen((m) async { - _messages[m.requestId!] = m; - await saveMessage(m); - }); + final subscription = stream.listen( + (msg) async { + _messages[msg.requestId!] = msg; + await saveMessage(msg); + }, + onError: (error) { + // Log or handle subscription errors + print('Error in subscription for session ${session.keyIndex}: $error'); + }, + cancelOnError: false, + ); _subscriptions[session.keyIndex] = subscription; return stream; } + Stream resubscribeOrder(String orderId) { + final session = _mostroService.getSessionByOrderId(orderId); + return _subscribe(session!); + } + Future> takeSellOrder( String orderId, int? amount, String? lnAddress) async { final session = @@ -56,29 +69,33 @@ class MostroRepository implements OrderRepository { Future saveMessages() async { for (var m in _messages.values.toList()) { - await _flutterSecureStorage.write( + await _secureStorage.write( key: '${SecureStorageKeys.message}-${m.requestId}', value: jsonEncode(m.toJson())); } } Future saveMessage(MostroMessage message) async { - await _flutterSecureStorage.write( + await _secureStorage.write( key: '${SecureStorageKeys.message}-${message.requestId}', value: jsonEncode(message.toJson())); } Future deleteMessage(String messageId) async { - await _flutterSecureStorage.delete( - key: '${SecureStorageKeys.message}-$messageId'); + _messages.remove(messageId); + await _secureStorage.delete(key: '${SecureStorageKeys.message}-$messageId'); } Future loadMessages() async { - final allKeys = await _flutterSecureStorage.readAll(); - for (var e in allKeys.entries) { - if (e.key.startsWith(SecureStorageKeys.message.value)) { - final message = MostroMessage.deserialized(e.value); - _messages[message.requestId!] = message; + final allEntries = await _secureStorage.readAll(); + for (final entry in allEntries.entries) { + if (entry.key.startsWith(SecureStorageKeys.message.value)) { + try { + final msg = MostroMessage.deserialized(entry.value); + _messages[msg.requestId!] = msg; + } catch (e) { + print('Error deserializing message for key ${entry.key}: $e'); + } } } } diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 8195a5a4..2166da92 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -7,35 +7,41 @@ import 'package:mostro_mobile/data/models/session.dart'; class SessionManager { final KeyManager _keyManager; - final FlutterSecureStorage _flutterSecureStorage; + final FlutterSecureStorage _secureStorage; final Map _sessions = {}; + Timer? _cleanupTimer; final int sessionExpirationHours = 48; static const cleanupIntervalMinutes = 30; static const maxBatchSize = 100; - SessionManager(this._keyManager, this._flutterSecureStorage) { - _init(); + SessionManager(this._keyManager, this._secureStorage) { _initializeCleanup(); } - Future _init() async { - final allKeys = await _flutterSecureStorage.readAll(); - for (var e in allKeys.entries) { - if (e.key.startsWith(SecureStorageKeys.sessionKey.value)) { - final session = await sessionJsonDecode(e.value); - _sessions[session.keyIndex] = session; + /// Call this after app startup to load sessions from storage + Future init() async { + final allEntries = await _secureStorage.readAll(); + for (final entry in allEntries.entries) { + if (entry.key.startsWith(SecureStorageKeys.sessionKey.value)) { + try { + final session = await _decodeSession(entry.value); + _sessions[session.keyIndex] = session; + } catch (e) { + print('Error decoding session for key ${entry.key}: $e'); + // Decide if you want to remove the corrupted entry + } } } } Future newSession({String? orderId}) async { - final keys = await _keyManager.getMasterKey(); + final masterKey = await _keyManager.getMasterKey(); final keyIndex = await _keyManager.getCurrentKeyIndex(); final tradeKey = await _keyManager.deriveTradeKey(); final session = Session( startTime: DateTime.now(), - masterKey: keys, + masterKey: masterKey, keyIndex: keyIndex, tradeKey: tradeKey, fullPrivacy: false, @@ -48,18 +54,11 @@ class SessionManager { Future saveSession(Session session) async { String sessionJson = jsonEncode(session.toJson()); - await _flutterSecureStorage.write( + await _secureStorage.write( key: '${SecureStorageKeys.sessionKey}${session.keyIndex}', value: sessionJson); } - Future getSession(int sessionId) async { - if (_sessions.containsKey(sessionId)) { - return _sessions[sessionId]; - } - return await loadSession('${SecureStorageKeys.sessionKey}$sessionId'); - } - Session? getSessionByOrderId(String orderId) { try { return _sessions.values.firstWhere((s) => s.orderId == orderId); @@ -68,53 +67,64 @@ class SessionManager { } } - Future loadSession(String sessionId) async { - String? sessionJson = await _flutterSecureStorage.read(key: sessionId); - if (sessionJson != null) { - return sessionJsonDecode(sessionJson); + Future loadSession(int keyIndex) async { + if (_sessions.containsKey(keyIndex)) { + return _sessions[keyIndex]; + } + final storedJson = await _secureStorage.read( + key: '${SecureStorageKeys.sessionKey}$keyIndex'); + if (storedJson != null) { + try { + final session = await _decodeSession(storedJson); + _sessions[keyIndex] = session; + return session; + } catch (e) { + print('Error decoding session index $keyIndex: $e'); + } } return null; } - Future sessionJsonDecode(String sessionJson) async { - final session = jsonDecode(sessionJson) as Map; - int index = session['key_index']; - session['master_key'] = await _keyManager.getMasterKey(); - session['trade_key'] = await _keyManager.deriveTradeKeyFromIndex(index); - return Session.fromJson(session); + Future _decodeSession(String jsonData) async { + final map = jsonDecode(jsonData) as Map; + final index = map['key_index'] as int; + final tradeKey = await _keyManager.deriveTradeKeyFromIndex(index); + final masterKey = await _keyManager.getMasterKey(); + map['trade_key'] = tradeKey; + map['master_key'] = masterKey; + return Session.fromJson(map); } Future deleteSession(int sessionId) async { _sessions.remove(sessionId); - await _flutterSecureStorage.delete( + await _secureStorage.delete( key: '${SecureStorageKeys.sessionKey}$sessionId'); } Future clearExpiredSessions() async { try { final now = DateTime.now(); - final allKeys = await _flutterSecureStorage.readAll(); - final entries = allKeys.entries + final allEntries = await _secureStorage.readAll(); + final entries = allEntries.entries .where((e) => e.key.startsWith(SecureStorageKeys.sessionKey.value)) .toList(); int processedCount = 0; for (final entry in entries) { if (processedCount >= maxBatchSize) break; - final key = entry.key; - final value = entry.value; try { - final session = Session.fromJson(jsonDecode(value)); - if (now.difference(session.startTime).inHours >= - sessionExpirationHours) { - await _flutterSecureStorage.delete(key: key); - _sessions.remove(session.keyIndex); + final sessionMap = jsonDecode(entry.value) as Map; + final startTime = DateTime.parse(sessionMap['startTime'] as String); + final index = sessionMap['key_index'] as int; + if (now.difference(startTime).inHours >= sessionExpirationHours) { + await _secureStorage.delete(key: entry.key); + _sessions.remove(index); processedCount++; } } catch (e) { - print('Error processing session $key: $e'); - await _flutterSecureStorage.delete(key: key); - _sessions.removeWhere((_, s) => 'session_${s.keyIndex}' == key); + print('Error processing session ${entry.key}: $e'); + // Possibly remove corrupted entry + await _secureStorage.delete(key: entry.key); processedCount++; } } @@ -124,6 +134,7 @@ class SessionManager { } void _initializeCleanup() { + _cleanupTimer?.cancel(); clearExpiredSessions(); _cleanupTimer = Timer.periodic(Duration(minutes: cleanupIntervalMinutes), (timer) { diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart new file mode 100644 index 00000000..df892316 --- /dev/null +++ b/lib/features/order/notfiers/order_notifier.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; +import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; +import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; + +class OrderNotifier extends StateNotifier { + final MostroRepository orderRepository; + final Ref ref; + final String orderId; + StreamSubscription? _orderSubscription; + + OrderNotifier({ + required this.orderRepository, + required this.orderId, + required this.ref, + }) : super(orderRepository.getOrderById(orderId) ?? + MostroMessage(action: Action.notFound, requestId: orderId)) { + subscribe(); + } + + Future subscribe() async { + final existingMessage = orderRepository.getOrderById(orderId); + if (existingMessage == null) { + // Possibly load from secure storage or handle error + print('Order $orderId not found in repository; subscription aborted.'); + return; + } + // If you have a direct stream from the repository for this order, set up: + // For example, if your repository has a method like `resubscribeOrder(orderId) => Stream` + final stream = await orderRepository.resubscribeOrder(orderId); + _orderSubscription = stream.listen((msg) { + state = msg; + _handleOrderUpdate(); + }, onError: (err) { + _handleError(err); + }); + } + + void _handleError(Object err) { + ref.read(notificationProvider.notifier).showInformation(err.toString()); + } + + void _handleOrderUpdate() { + final navProvider = ref.read(navigationProvider.notifier); + final notifProvider = ref.read(notificationProvider.notifier); + + switch (state.action) { + case Action.newOrder: + navProvider.go('/order_confirmed/${state.requestId!}'); + break; + case Action.payInvoice: + navProvider.go('/pay_invoice/${state.requestId!}'); + break; + case Action.outOfRangeSatsAmount: + notifProvider.showInformation('Sats out of range'); + break; + case Action.outOfRangeFiatAmount: + notifProvider.showInformation('Fiant amount out of range'); + break; + case Action.waitingSellerToPay: + notifProvider.showInformation('Waiting Seller to pay'); + break; + case Action.waitingBuyerInvoice: + notifProvider.showInformation('Waiting Buy Invoice'); + break; + case Action.buyerTookOrder: + notifProvider.showInformation('Buyer took order'); + break; + case Action.fiatSentOk: + case Action.holdInvoicePaymentSettled: + case Action.rate: + case Action.rateReceived: + case Action.canceled: + case Action.cooperativeCancelInitiatedByYou: + case Action.disputeInitiatedByYou: + case Action.adminSettled: + default: + notifProvider.showInformation(state.action.toString()); + break; + } + } + + @override + void dispose() { + _orderSubscription?.cancel(); + super.dispose(); + } +} diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart new file mode 100644 index 00000000..ca1bd361 --- /dev/null +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -0,0 +1,15 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; + +final orderNotifierProvider = StateNotifierProvider.family( + (ref, orderId) { + final repo = ref.watch(mostroRepositoryProvider); + return OrderNotifier( + orderRepository: repo, + orderId: orderId, + ref: ref, + ); + }, +); \ No newline at end of file diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 07434c12..27e4071d 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -28,6 +28,11 @@ class MostroService { }); } + Session? getSessionByOrderId(String orderId) { + final session = _sessionManager.getSessionByOrderId(orderId); + return session; + } + Future takeSellOrder( String orderId, int? amount, String? lnAddress) async { final session = await _sessionManager.newSession(orderId: orderId); diff --git a/lib/shared/providers/init_provider.dart b/lib/shared/providers/init_provider.dart index 7ae9ea37..0b402dc4 100644 --- a/lib/shared/providers/init_provider.dart +++ b/lib/shared/providers/init_provider.dart @@ -1,13 +1,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; -import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; final appInitializerProvider = FutureProvider((ref) async { - final flutterSecureStorage = ref.read(secureStorageProvider); - flutterSecureStorage.deleteAll(); final keyManager = ref.read(keyManagerProvider); bool hasMaster = await keyManager.hasMasterKey(); if (!hasMaster) { await keyManager.generateAndStoreMasterKey(); } + final mostroRepository = ref.read(mostroRepositoryProvider); + await mostroRepository.loadMessages(); + + for (final msg in mostroRepository.allMessages) { + final orderId = msg.requestId!; + ref.read(orderNotifierProvider(orderId).notifier); + } }); From a055be9ff62d5bf574450a97fa666912c3578f33 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 25 Dec 2024 17:44:20 -0800 Subject: [PATCH 015/149] change order > content to order > payload --- lib/data/models/mostro_message.dart | 10 +++++----- lib/services/mostro_service.dart | 10 +++++----- test/models/order_test.dart | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 2b3b7e59..dd4b9c41 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -19,12 +19,12 @@ class MostroMessage { 'version': Config.mostroVersion, 'id': requestId, 'action': action.value, - 'content': _payload?.toJson(), + 'payload': _payload?.toJson(), }, }; if (tradeIndex != null) { jMap['order']?['trade_index'] = tradeIndex; - jMap['order']?['content'] = [jMap['order']?['content']]; + jMap['order']?['payload'] = [jMap['order']?['payload']]; } return jMap; } @@ -43,8 +43,8 @@ class MostroMessage { ? Action.fromString(order['action']) : throw FormatException('Missing action field'); - final content = order['content'] != null - ? Payload.fromJson(event['order']['content']) as T + final payload = order['payload'] != null + ? Payload.fromJson(event['order']['payload']) as T : null; final tradeIndex = @@ -53,7 +53,7 @@ class MostroMessage { return MostroMessage( action: action, requestId: order['id'], - payload: content, + payload: payload, tradeIndex: tradeIndex, ); } catch (e) { diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 27e4071d..24459254 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -44,13 +44,13 @@ class MostroService { ? {'amount': amount} : null; - final content = newMessage(Action.takeSell, orderId, content: order); + final content = newMessage(Action.takeSell, orderId, payload: order); await sendMessage(orderId, Config.mostroPubKey, content); return session; } Future sendInvoice(String orderId, String invoice) async { - final content = newMessage(Action.addInvoice, orderId, content: { + final content = newMessage(Action.addInvoice, orderId, payload: { 'payment_request': [ null, invoice, @@ -63,7 +63,7 @@ class MostroService { Future takeBuyOrder(String orderId, int? amount) async { final session = await _sessionManager.newSession(orderId: orderId); final amt = amount != null ? {'amount': amount} : null; - final content = newMessage(Action.takeBuy, orderId, content: amt); + final content = newMessage(Action.takeBuy, orderId, payload: amt); await sendMessage(orderId, Config.mostroPubKey, content); return session; } @@ -92,13 +92,13 @@ class MostroService { } Map newMessage(Action actionType, String orderId, - {Object? content}) { + {Object? payload}) { return { 'order': { 'version': Config.mostroVersion, 'id': orderId, 'action': actionType.value, - 'content': content, + 'payload': payload, }, }; } diff --git a/test/models/order_test.dart b/test/models/order_test.dart index ee7a5432..4625de05 100644 --- a/test/models/order_test.dart +++ b/test/models/order_test.dart @@ -35,7 +35,7 @@ void main() { final jsonData = await loadJson('test/examples/new_sell_order.json'); // Parse JSON to model - final orderData = jsonData['order']['order']['content']['order']; + final orderData = jsonData['order']['order']['payload']['order']; final order = Order.fromJson(orderData); // Validate model properties From e993b95b48ad68f15145f1975506b3545d3851a9 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 25 Dec 2024 23:51:07 -0800 Subject: [PATCH 016/149] Initialize and load sessions at startup --- lib/app/app.dart | 2 +- lib/data/repositories/session_manager.dart | 2 +- lib/features/order/notfiers/order_notifier.dart | 5 +---- .../take_order/notifiers/take_sell_order_notifier.dart | 2 +- lib/features/take_order/screens/take_sell_order_screen.dart | 2 +- lib/main.dart | 1 - .../providers/{init_provider.dart => app_init_provider.dart} | 3 +++ 7 files changed, 8 insertions(+), 9 deletions(-) rename lib/shared/providers/{init_provider.dart => app_init_provider.dart} (82%) diff --git a/lib/app/app.dart b/lib/app/app.dart index 4960e35f..4d54eb4f 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -7,7 +7,7 @@ import 'package:mostro_mobile/app/app_theme.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/features/auth/notifiers/auth_state.dart'; -import 'package:mostro_mobile/shared/providers/init_provider.dart'; +import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; class MostroApp extends ConsumerWidget { const MostroApp({super.key}); diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 2166da92..968f8514 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -114,7 +114,7 @@ class SessionManager { if (processedCount >= maxBatchSize) break; try { final sessionMap = jsonDecode(entry.value) as Map; - final startTime = DateTime.parse(sessionMap['startTime'] as String); + final startTime = DateTime.parse(sessionMap['start_time'] as String); final index = sessionMap['key_index'] as int; if (now.difference(startTime).inHours >= sessionExpirationHours) { await _secureStorage.delete(key: entry.key); diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index df892316..c83f8310 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -24,13 +24,10 @@ class OrderNotifier extends StateNotifier { Future subscribe() async { final existingMessage = orderRepository.getOrderById(orderId); if (existingMessage == null) { - // Possibly load from secure storage or handle error print('Order $orderId not found in repository; subscription aborted.'); return; } - // If you have a direct stream from the repository for this order, set up: - // For example, if your repository has a method like `resubscribeOrder(orderId) => Stream` - final stream = await orderRepository.resubscribeOrder(orderId); + final stream = orderRepository.resubscribeOrder(orderId); _orderSubscription = stream.listen((msg) { state = msg; _handleOrderUpdate(); diff --git a/lib/features/take_order/notifiers/take_sell_order_notifier.dart b/lib/features/take_order/notifiers/take_sell_order_notifier.dart index d7539366..d542508d 100644 --- a/lib/features/take_order/notifiers/take_sell_order_notifier.dart +++ b/lib/features/take_order/notifiers/take_sell_order_notifier.dart @@ -33,7 +33,7 @@ class TakeSellOrderNotifier extends StateNotifier { ref.read(notificationProvider.notifier).showInformation(err.toString()); } - void sendInvoice(String orderId, String invoice, int amount) async { + void sendInvoice(String orderId, String invoice, int? amount) async { await _orderRepository.sendInvoice(orderId, invoice); } diff --git a/lib/features/take_order/screens/take_sell_order_screen.dart b/lib/features/take_order/screens/take_sell_order_screen.dart index 51cc8425..e1f59a3c 100644 --- a/lib/features/take_order/screens/take_sell_order_screen.dart +++ b/lib/features/take_order/screens/take_sell_order_screen.dart @@ -119,7 +119,7 @@ class TakeSellOrderScreen extends ConsumerWidget { final order = (state.payload is Order) ? state.payload as Order : null; final TextEditingController invoiceController = TextEditingController(); - final int val = order!.amount; + final val = order?.amount; return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar( diff --git a/lib/main.dart b/lib/main.dart index 888086e6..72542c50 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mostro_mobile/app/app.dart'; -import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; diff --git a/lib/shared/providers/init_provider.dart b/lib/shared/providers/app_init_provider.dart similarity index 82% rename from lib/shared/providers/init_provider.dart rename to lib/shared/providers/app_init_provider.dart index 0b402dc4..ddf94a9d 100644 --- a/lib/shared/providers/init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; final appInitializerProvider = FutureProvider((ref) async { final keyManager = ref.read(keyManagerProvider); @@ -9,6 +10,8 @@ final appInitializerProvider = FutureProvider((ref) async { if (!hasMaster) { await keyManager.generateAndStoreMasterKey(); } + final sessionManager = ref.read(sessionManagerProvider); + await sessionManager.init(); final mostroRepository = ref.read(mostroRepositoryProvider); await mostroRepository.loadMessages(); From 76d59713b30bd5d95b5e178045169f19cb3e081f Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 26 Dec 2024 11:04:20 -0800 Subject: [PATCH 017/149] Change rumor content to array --- lib/app/config.dart | 10 +++------- lib/data/models/mostro_message.dart | 1 - lib/data/models/session.dart | 8 +++----- lib/services/mostro_service.dart | 9 ++++++--- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/lib/app/config.dart b/lib/app/config.dart index 6e627f9d..8f9930f8 100644 --- a/lib/app/config.dart +++ b/lib/app/config.dart @@ -3,13 +3,9 @@ import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ - //'ws://127.0.0.1:7000', - 'ws://192.168.1.144:7000' - //'wss://relay.mostro.network', - //'ws://10.0.2.2:7000', - //'wss://relay.damus.io', - //'wss://relay.nostr.net', - // Agrega más relays aquí si es necesario + //'ws://127.0.0.1:7000', // localhost + //'ws://10.0.2.2:7000', // mobile emulator + 'wss://relay.mostro.network', ]; // hexkey de Mostro diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index dd4b9c41..5bdf602c 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -24,7 +24,6 @@ class MostroMessage { }; if (tradeIndex != null) { jMap['order']?['trade_index'] = tradeIndex; - jMap['order']?['payload'] = [jMap['order']?['payload']]; } return jMap; } diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 06002978..76a1ff9b 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -30,12 +30,10 @@ class Session { factory Session.fromJson(Map json) { return Session( startTime: DateTime.parse(json['start_time']), - masterKey: NostrKeyPairs( - private: json['master_key'], - ), + masterKey: json['master_key'], orderId: json['event_id'], - keyIndex: int.parse(json['key_index']), - tradeKey: NostrKeyPairs(private: json['trade_key']), + keyIndex: json['key_index'], + tradeKey: json['trade_key'], fullPrivacy: json['full_privacy'], ); } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 24459254..92f3b1ef 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -70,7 +70,10 @@ class MostroService { Future publishOrder(MostroMessage order) async { final session = await _sessionManager.newSession(); - final content = jsonEncode(order.toJson()); + final message = order.toJson(); + final sha256Digest = sha256.convert(utf8.encode(jsonEncode(message))); + final signature = session.tradeKey.sign(sha256Digest.toString()); + final content = jsonEncode([message, signature]); final event = await createNIP59Event(content, Config.mostroPubKey, session); await _nostrService.publishEvent(event); return session; @@ -108,13 +111,13 @@ class MostroService { try { final session = _sessionManager.getSessionByOrderId(orderId); String finalContent; - if (session!.fullPrivacy) { + if (!session!.fullPrivacy) { content['order']?['trade_index'] = session.keyIndex; final sha256Digest = sha256.convert(utf8.encode(jsonEncode(content))); final signature = session.tradeKey.sign(sha256Digest.toString()); finalContent = jsonEncode([content, signature]); } else { - finalContent = jsonEncode(content); + finalContent = jsonEncode([content, null]); } final event = await createNIP59Event(finalContent, receiverPubkey, session); From 0bb91c6ee47e45e0b553a4650130cb535fad3d2c Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Mon, 30 Dec 2024 20:59:23 -0300 Subject: [PATCH 018/149] Debugging signing --- lib/services/mostro_service.dart | 34 +++++++++----- pubspec.lock | 76 +++++++++++++++++++++++++++++++- pubspec.yaml | 3 +- 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 92f3b1ef..33b10f82 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:mostro_mobile/app/config.dart'; @@ -70,10 +71,20 @@ class MostroService { Future publishOrder(MostroMessage order) async { final session = await _sessionManager.newSession(); - final message = order.toJson(); - final sha256Digest = sha256.convert(utf8.encode(jsonEncode(message))); - final signature = session.tradeKey.sign(sha256Digest.toString()); - final content = jsonEncode([message, signature]); + String content; + if (!session.fullPrivacy) { + order.tradeIndex = session.keyIndex; + final message = order.toJson(); + + final serializedEvent = jsonEncode(message); + final bytes = utf8.encode(serializedEvent); + final digest = sha256.convert(bytes); + final hash = hex.encode(digest.bytes); + final signature = session.tradeKey.sign(hash); + content = jsonEncode([message, signature]); + } else { + content = jsonEncode([order.toJson(), null]); + } final event = await createNIP59Event(content, Config.mostroPubKey, session); await _nostrService.publishEvent(event); return session; @@ -113,8 +124,11 @@ class MostroService { String finalContent; if (!session!.fullPrivacy) { content['order']?['trade_index'] = session.keyIndex; - final sha256Digest = sha256.convert(utf8.encode(jsonEncode(content))); - final signature = session.tradeKey.sign(sha256Digest.toString()); + final sha256Digest = + sha256.convert(utf8.encode(jsonEncode(content))).bytes; + final hashHex = base64.encode(sha256Digest); + final signature = session.tradeKey.sign(hashHex); + print(signature.codeUnits); finalContent = jsonEncode([content, signature]); } else { finalContent = jsonEncode([content, null]); @@ -129,12 +143,12 @@ class MostroService { Future createNIP59Event( String content, String recipientPubKey, Session session) async { - final encryptedContent = await _nostrService.createRumor( - content, recipientPubKey, session.tradeKey); + final keySet = session.fullPrivacy ? session.tradeKey : session.masterKey; - final wrapperKeyPair = await _nostrService.generateKeyPair(); + final encryptedContent = + await _nostrService.createRumor(content, recipientPubKey, keySet); - final keySet = session.fullPrivacy ? session.masterKey : session.tradeKey; + final wrapperKeyPair = await _nostrService.generateKeyPair(); String sealedContent = await _nostrService.createSeal( keySet, wrapperKeyPair.private, recipientPubKey, encryptedContent); diff --git a/pubspec.lock b/pubspec.lock index 5d4fa4bc..6aa6c99f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -134,6 +134,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + url: "https://pub.dev" + source: hosted + version: "2.4.13" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + url: "https://pub.dev" + source: hosted + version: "7.3.2" built_collection: dependency: transitive description: @@ -146,10 +186,10 @@ packages: dependency: transitive description: name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2" url: "https://pub.dev" source: hosted - version: "8.9.2" + version: "8.9.3" characters: dependency: transitive description: @@ -478,6 +518,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.2.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" heroicons: dependency: "direct main" description: @@ -868,6 +916,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" + url: "https://pub.dev" + source: hosted + version: "1.4.0" qr: dependency: transitive description: @@ -1049,6 +1105,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -1105,6 +1169,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.7.0" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3147253a..69c9b35e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -85,10 +85,11 @@ dev_dependencies: # 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 + mockito: ^5.4.4 + build_runner: ^2.4.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec From 42407236bdd39b36465ea4bf66ca0c7b9e1deb4c Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Tue, 31 Dec 2024 12:25:04 -0300 Subject: [PATCH 019/149] Added support for cant-do --- lib/data/models/cant_do.dart | 21 +++++++++++++++++++++ lib/data/models/mostro_message.dart | 15 ++++++--------- lib/data/models/order.dart | 13 ++++++------- lib/data/models/payload.dart | 3 +++ lib/services/mostro_service.dart | 7 +++++-- 5 files changed, 41 insertions(+), 18 deletions(-) create mode 100644 lib/data/models/cant_do.dart diff --git a/lib/data/models/cant_do.dart b/lib/data/models/cant_do.dart new file mode 100644 index 00000000..61a40cec --- /dev/null +++ b/lib/data/models/cant_do.dart @@ -0,0 +1,21 @@ +import 'package:mostro_mobile/data/models/payload.dart'; + +class CantDo implements Payload { + final String cantDo; + + factory CantDo.fromJson(Map json) { + return CantDo(cantDo: json['cant_do']); + } + + CantDo({required this.cantDo}); + + @override + Map toJson() { + // TODO: implement toJson + throw UnimplementedError(); + } + + @override + // TODO: implement type + String get type => 'can_do'; +} diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 5bdf602c..b6d6d65f 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -14,24 +14,21 @@ class MostroMessage { : _payload = payload; Map toJson() { - final jMap = { + return { 'order': { 'version': Config.mostroVersion, - 'id': requestId, + 'request_id': requestId, + 'trade_index': tradeIndex, 'action': action.value, 'payload': _payload?.toJson(), }, }; - if (tradeIndex != null) { - jMap['order']?['trade_index'] = tradeIndex; - } - return jMap; } factory MostroMessage.deserialized(String data) { try { final decoded = jsonDecode(data); - final event = decoded as Map; + final event = decoded[0] as Map; final order = event['order'] != null ? event['order'] as Map : event['cant-do'] != null @@ -43,7 +40,7 @@ class MostroMessage { : throw FormatException('Missing action field'); final payload = order['payload'] != null - ? Payload.fromJson(event['order']['payload']) as T + ? Payload.fromJson(order['payload']) as T : null; final tradeIndex = @@ -51,7 +48,7 @@ class MostroMessage { return MostroMessage( action: action, - requestId: order['id'], + requestId: order['request_id'], payload: payload, tradeIndex: tradeIndex, ); diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart index 06c3134d..bbe63e43 100644 --- a/lib/data/models/order.dart +++ b/lib/data/models/order.dart @@ -51,15 +51,19 @@ class Order implements Payload { 'status': status.value, 'amount': amount, 'fiat_code': fiatCode, + 'min_amount': minAmount, + 'max_amount': maxAmount, 'fiat_amount': fiatAmount, 'payment_method': paymentMethod, 'premium': premium, + 'created_at': createdAt, + 'expires_at': expiresAt, + 'buyer_token': buyerToken, + 'seller_token': sellerToken, } }; 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; } @@ -67,11 +71,6 @@ class Order implements Payload { 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; } diff --git a/lib/data/models/payload.dart b/lib/data/models/payload.dart index 474c0fdd..c2176964 100644 --- a/lib/data/models/payload.dart +++ b/lib/data/models/payload.dart @@ -1,3 +1,4 @@ +import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/payment_request.dart'; @@ -10,6 +11,8 @@ abstract class Payload { return Order.fromJson(json['order']); } else if (json.containsKey('payment_request')) { return PaymentRequest.fromJson(json['payment_request']); + } else if (json.containsKey('cant_do')) { + return CantDo.fromJson(json); } throw UnsupportedError('Unknown content type'); } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 33b10f82..88e3b80c 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -76,11 +76,14 @@ class MostroService { order.tradeIndex = session.keyIndex; final message = order.toJson(); - final serializedEvent = jsonEncode(message); + final serializedEvent = jsonEncode(message['order']); final bytes = utf8.encode(serializedEvent); final digest = sha256.convert(bytes); final hash = hex.encode(digest.bytes); final signature = session.tradeKey.sign(hash); + print(signature); + print(hash); + print(serializedEvent); content = jsonEncode([message, signature]); } else { content = jsonEncode([order.toJson(), null]); @@ -143,7 +146,7 @@ class MostroService { Future createNIP59Event( String content, String recipientPubKey, Session session) async { - final keySet = session.fullPrivacy ? session.tradeKey : session.masterKey; + final keySet = session.fullPrivacy ? session.tradeKey : session.tradeKey; final encryptedContent = await _nostrService.createRumor(content, recipientPubKey, keySet); From da5b9826881098c1ceeac8fbae956cf45698bda2 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 2 Jan 2025 19:44:49 -0300 Subject: [PATCH 020/149] add buy/sell order now working --- lib/data/models/mostro_message.dart | 12 +++++------ lib/data/models/order.dart | 2 +- lib/data/repositories/mostro_repository.dart | 14 ++++++++----- .../notifiers/add_order_notifier.dart | 8 +++---- .../key_manager/key_manager_provider.dart | 1 + .../order/notfiers/order_notifier.dart | 6 +++--- .../notifiers/take_buy_order_notifier.dart | 4 +--- lib/services/mostro_service.dart | 21 +++++++++---------- lib/services/nostr_service.dart | 7 ++++--- lib/shared/providers/app_init_provider.dart | 2 +- lib/shared/utils/nostr_utils.dart | 16 +++++++------- 11 files changed, 47 insertions(+), 46 deletions(-) diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index b6d6d65f..c5dd14c5 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -4,20 +4,19 @@ import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/payload.dart'; class MostroMessage { - String? requestId; + String? id; final Action action; int? tradeIndex; T? _payload; - MostroMessage( - {required this.action, this.requestId, T? payload, this.tradeIndex}) + MostroMessage({required this.action, this.id, T? payload, this.tradeIndex}) : _payload = payload; Map toJson() { return { 'order': { 'version': Config.mostroVersion, - 'request_id': requestId, + 'request_id': id, 'trade_index': tradeIndex, 'action': action.value, 'payload': _payload?.toJson(), @@ -43,12 +42,11 @@ class MostroMessage { ? Payload.fromJson(order['payload']) as T : null; - final tradeIndex = - order['trade_index'] != null ? int.parse(order['trade_index']) : null; + final tradeIndex = order['trade_index']; return MostroMessage( action: action, - requestId: order['request_id'], + id: order['id'], payload: payload, tradeIndex: tradeIndex, ); diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart index bbe63e43..8a8e2a5f 100644 --- a/lib/data/models/order.dart +++ b/lib/data/models/order.dart @@ -33,7 +33,7 @@ class Order implements Payload { this.maxAmount, required this.fiatAmount, required this.paymentMethod, - this.premium = 1, + this.premium = 0, this.masterBuyerPubkey, this.masterSellerPubkey, this.buyerInvoice, diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index 81d377f1..d6602424 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; @@ -23,8 +24,11 @@ class MostroRepository implements OrderRepository { final stream = _mostroService.subscribe(session); final subscription = stream.listen( (msg) async { - _messages[msg.requestId!] = msg; - await saveMessage(msg); + // TODO: handle other message payloads + if (msg.payload is Order) { + _messages[msg.id!] = msg; + await saveMessage(msg); + } }, onError: (error) { // Log or handle subscription errors @@ -70,14 +74,14 @@ class MostroRepository implements OrderRepository { Future saveMessages() async { for (var m in _messages.values.toList()) { await _secureStorage.write( - key: '${SecureStorageKeys.message}-${m.requestId}', + key: '${SecureStorageKeys.message}-${m.id}', value: jsonEncode(m.toJson())); } } Future saveMessage(MostroMessage message) async { await _secureStorage.write( - key: '${SecureStorageKeys.message}-${message.requestId}', + key: '${SecureStorageKeys.message}-${message.id}', value: jsonEncode(message.toJson())); } @@ -92,7 +96,7 @@ class MostroRepository implements OrderRepository { if (entry.key.startsWith(SecureStorageKeys.message.value)) { try { final msg = MostroMessage.deserialized(entry.value); - _messages[msg.requestId!] = msg; + _messages[msg.id!] = msg; } catch (e) { print('Error deserializing message for key ${entry.key}: $e'); } diff --git a/lib/features/add_order/notifiers/add_order_notifier.dart b/lib/features/add_order/notifiers/add_order_notifier.dart index 861ee625..c2dcc93c 100644 --- a/lib/features/add_order/notifiers/add_order_notifier.dart +++ b/lib/features/add_order/notifiers/add_order_notifier.dart @@ -27,8 +27,8 @@ class AddOrderNotifier extends StateNotifier { paymentMethod: paymentMethod, buyerInvoice: lnAddress, ); - final message = MostroMessage( - action: Action.newOrder, requestId: null, payload: order); + final message = + MostroMessage(action: Action.newOrder, id: null, payload: order); try { final stream = await _orderRepository.publishOrder(message); @@ -51,10 +51,10 @@ class AddOrderNotifier extends StateNotifier { switch (state.action) { case Action.newOrder: - navProvider.go('/order_confirmed/${state.requestId!}'); + navProvider.go('/order_confirmed/${state.id!}'); break; case Action.payInvoice: - navProvider.go('/pay_invoice/${state.requestId!}'); + navProvider.go('/pay_invoice/${state.id!}'); break; case Action.outOfRangeSatsAmount: notifProvider.showInformation('Sats out of range'); diff --git a/lib/features/key_manager/key_manager_provider.dart b/lib/features/key_manager/key_manager_provider.dart index c7b38601..b912a316 100644 --- a/lib/features/key_manager/key_manager_provider.dart +++ b/lib/features/key_manager/key_manager_provider.dart @@ -7,6 +7,7 @@ import 'package:mostro_mobile/shared/providers/storage_providers.dart'; final keyManagerProvider = Provider((ref) { final secureStorage = ref.watch(secureStorageProvider); final sharedPrefs = ref.watch(sharedPreferencesProvider); + final keyStorage = KeyStorage(secureStorage: secureStorage, sharedPrefs: sharedPrefs); final keyDerivator = KeyDerivator("m/44'/1237'/38383'/0"); diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index c83f8310..4722665e 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -17,7 +17,7 @@ class OrderNotifier extends StateNotifier { required this.orderId, required this.ref, }) : super(orderRepository.getOrderById(orderId) ?? - MostroMessage(action: Action.notFound, requestId: orderId)) { + MostroMessage(action: Action.notFound, id: orderId)) { subscribe(); } @@ -46,10 +46,10 @@ class OrderNotifier extends StateNotifier { switch (state.action) { case Action.newOrder: - navProvider.go('/order_confirmed/${state.requestId!}'); + navProvider.go('/order_confirmed/${state.id!}'); break; case Action.payInvoice: - navProvider.go('/pay_invoice/${state.requestId!}'); + navProvider.go('/pay_invoice/${state.id!}'); break; case Action.outOfRangeSatsAmount: notifProvider.showInformation('Sats out of range'); diff --git a/lib/features/take_order/notifiers/take_buy_order_notifier.dart b/lib/features/take_order/notifiers/take_buy_order_notifier.dart index fea9c8a3..8f5c9ca2 100644 --- a/lib/features/take_order/notifiers/take_buy_order_notifier.dart +++ b/lib/features/take_order/notifiers/take_buy_order_notifier.dart @@ -36,9 +36,7 @@ class TakeBuyOrderNotifier extends StateNotifier { switch (state.action) { case Action.payInvoice: - ref - .read(navigationProvider.notifier) - .go('/pay_invoice/${state.requestId!}'); + ref.read(navigationProvider.notifier).go('/pay_invoice/${state.id!}'); break; case Action.waitingBuyerInvoice: notifProvider.showInformation('Waiting Buy Invoice'); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 88e3b80c..9782b755 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -21,8 +21,8 @@ class MostroService { final decryptedEvent = await _nostrService.decryptNIP59Event( event, session.tradeKey.private); final msg = MostroMessage.deserialized(decryptedEvent.content!); - if (session.orderId == null && msg.requestId != null) { - session.orderId = msg.requestId; + if (session.orderId == null && msg.id != null) { + session.orderId = msg.id; await _sessionManager.saveSession(session); } return msg; @@ -81,9 +81,6 @@ class MostroService { final digest = sha256.convert(bytes); final hash = hex.encode(digest.bytes); final signature = session.tradeKey.sign(hash); - print(signature); - print(hash); - print(serializedEvent); content = jsonEncode([message, signature]); } else { content = jsonEncode([order.toJson(), null]); @@ -113,6 +110,8 @@ class MostroService { return { 'order': { 'version': Config.mostroVersion, + 'request_id': null, + 'trade_index': null, 'id': orderId, 'action': actionType.value, 'payload': payload, @@ -128,10 +127,9 @@ class MostroService { if (!session!.fullPrivacy) { content['order']?['trade_index'] = session.keyIndex; final sha256Digest = - sha256.convert(utf8.encode(jsonEncode(content))).bytes; - final hashHex = base64.encode(sha256Digest); + sha256.convert(utf8.encode(jsonEncode(content['order']))); + final hashHex = hex.encode(sha256Digest.bytes); final signature = session.tradeKey.sign(hashHex); - print(signature.codeUnits); finalContent = jsonEncode([content, signature]); } else { finalContent = jsonEncode([content, null]); @@ -141,15 +139,16 @@ class MostroService { await _nostrService.publishEvent(event); } catch (e) { // catch and throw and log and stuff + print(e); } } Future createNIP59Event( String content, String recipientPubKey, Session session) async { - final keySet = session.fullPrivacy ? session.tradeKey : session.tradeKey; + final keySet = session.fullPrivacy ? session.tradeKey : session.masterKey; - final encryptedContent = - await _nostrService.createRumor(content, recipientPubKey, keySet); + final encryptedContent = await _nostrService.createRumor( + session.tradeKey, keySet.private, recipientPubKey, content); final wrapperKeyPair = await _nostrService.generateKeyPair(); diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 189337ab..e7956f4a 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -106,9 +106,10 @@ class NostrService { return NostrUtils.decryptNIP59Event(event, privateKey); } - Future createRumor(String content, String recipientPubKey, - NostrKeyPairs senderPrivateKey) async { - return NostrUtils.createRumor(content, recipientPubKey, senderPrivateKey); + Future createRumor(NostrKeyPairs senderKeyPair, String wrapperKey, + String recipientPubKey, String content) async { + return NostrUtils.createRumor( + senderKeyPair, wrapperKey, recipientPubKey, content); } Future createSeal(NostrKeyPairs senderKeyPair, String wrapperKey, diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index ddf94a9d..15ba798d 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -16,7 +16,7 @@ final appInitializerProvider = FutureProvider((ref) async { await mostroRepository.loadMessages(); for (final msg in mostroRepository.allMessages) { - final orderId = msg.requestId!; + final orderId = msg.id!; ref.read(orderNotifierProvider(orderId).notifier); } }); diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 1bf4c9dd..8cf8f5d0 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -144,11 +144,11 @@ class NostrUtils { return now.subtract(Duration(seconds: randomSeconds)); } - static Future createRumor(String content, String recipientPubKey, - NostrKeyPairs senderPrivateKey) async { + static Future createRumor(NostrKeyPairs senderKeyPair, + String wrapperKey, String recipientPubKey, String content) async { final rumorEvent = NostrEvent.fromPartialData( kind: 1, - keyPairs: senderPrivateKey, + keyPairs: senderKeyPair, content: content, createdAt: DateTime.now(), tags: [ @@ -157,8 +157,8 @@ class NostrUtils { ); try { - return await _encryptNIP44(jsonEncode(rumorEvent.toMap()), - senderPrivateKey.private, recipientPubKey); + return await _encryptNIP44( + jsonEncode(rumorEvent.toMap()), wrapperKey, recipientPubKey); } catch (e) { throw Exception('Failed to encrypt content: $e'); } @@ -213,12 +213,12 @@ class NostrUtils { final senderKeyPair = generateKeyPairFromPrivateKey(senderPrivateKey); String encryptedContent = - await createRumor(content, recipientPubKey, senderKeyPair); + await createRumor(senderKeyPair, senderKeyPair.private, recipientPubKey, content); final wrapperKeyPair = generateKeyPair(); - String sealedContent = - await createSeal(senderKeyPair, wrapperKeyPair.private, recipientPubKey, encryptedContent); + String sealedContent = await createSeal(senderKeyPair, + wrapperKeyPair.private, recipientPubKey, encryptedContent); final wrapEvent = await createWrap(wrapperKeyPair, sealedContent, recipientPubKey); From 3d179462ec0902de6f11ee1a6b8872488ee544e9 Mon Sep 17 00:00:00 2001 From: Biz Date: Fri, 3 Jan 2025 06:53:12 -0800 Subject: [PATCH 021/149] Update lib/data/models/cant_do.dart Fixes type Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- lib/data/models/cant_do.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/data/models/cant_do.dart b/lib/data/models/cant_do.dart index 61a40cec..0c75ace4 100644 --- a/lib/data/models/cant_do.dart +++ b/lib/data/models/cant_do.dart @@ -17,5 +17,5 @@ class CantDo implements Payload { @override // TODO: implement type - String get type => 'can_do'; + String get type => 'cant_do'; } From f295e4a45ff8cfedcf5b155856b75bb37b216126 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Fri, 10 Jan 2025 20:57:58 -0300 Subject: [PATCH 022/149] should fix https://github.com/MostroP2P/mobile/pull/34#pullrequestreview-2533362957 --- lib/features/key_manager/key_manager_provider.dart | 2 +- .../notifiers/take_sell_order_notifier.dart | 1 - .../screens/add_lightning_invoice_screen.dart | 14 ++++++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/features/key_manager/key_manager_provider.dart b/lib/features/key_manager/key_manager_provider.dart index b912a316..a5ff9caa 100644 --- a/lib/features/key_manager/key_manager_provider.dart +++ b/lib/features/key_manager/key_manager_provider.dart @@ -12,4 +12,4 @@ final keyManagerProvider = Provider((ref) { KeyStorage(secureStorage: secureStorage, sharedPrefs: sharedPrefs); final keyDerivator = KeyDerivator("m/44'/1237'/38383'/0"); return KeyManager(keyStorage, keyDerivator); -}); +}); \ No newline at end of file diff --git a/lib/features/take_order/notifiers/take_sell_order_notifier.dart b/lib/features/take_order/notifiers/take_sell_order_notifier.dart index d542508d..2d46633b 100644 --- a/lib/features/take_order/notifiers/take_sell_order_notifier.dart +++ b/lib/features/take_order/notifiers/take_sell_order_notifier.dart @@ -73,7 +73,6 @@ class TakeSellOrderNotifier extends StateNotifier { } void cancelOrder() { - //state = state.copyWith(status: TakeSellOrderStatus.cancelled); dispose(); } diff --git a/lib/features/take_order/screens/add_lightning_invoice_screen.dart b/lib/features/take_order/screens/add_lightning_invoice_screen.dart index b4eb3fa0..24170e23 100644 --- a/lib/features/take_order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/add_lightning_invoice_screen.dart @@ -3,22 +3,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/app/app_theme.dart'; import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/features/take_order/providers/order_notifier_providers.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as action; class AddLightningInvoiceScreen extends ConsumerWidget { final String orderId; final int sats = 0; const AddLightningInvoiceScreen({super.key, required this.orderId}); - + @override Widget build(BuildContext context, WidgetRef ref) { final mostroRepo = ref.read(mostroRepositoryProvider); final message = mostroRepo.getOrderById(orderId); final order = message?.getPayload(); - final amount = order?.amount; final TextEditingController invoiceController = TextEditingController(); @@ -60,6 +61,15 @@ class AddLightningInvoiceScreen extends ConsumerWidget { child: ElevatedButton( onPressed: () { mostroRepo.cancelOrder(orderId); + if (message?.action == action.Action.takeBuy) { + final controller = ref.read( + takeBuyOrderNotifierProvider(orderId).notifier); + controller.dispose(); + } else if (message?.action == action.Action.takeSell) { + final controller = ref.read( + takeSellOrderNotifierProvider(orderId).notifier); + controller.dispose(); + } context.go('/'); }, style: ElevatedButton.styleFrom( From d81f1cb92bcef2f950f52b21ff0bc71e77dadc3c Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 11 Jan 2025 03:15:04 -0800 Subject: [PATCH 023/149] Update lib/features/take_order/screens/add_lightning_invoice_screen.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../screens/add_lightning_invoice_screen.dart | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/lib/features/take_order/screens/add_lightning_invoice_screen.dart b/lib/features/take_order/screens/add_lightning_invoice_screen.dart index 24170e23..4b77a2e1 100644 --- a/lib/features/take_order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/add_lightning_invoice_screen.dart @@ -61,14 +61,33 @@ class AddLightningInvoiceScreen extends ConsumerWidget { child: ElevatedButton( onPressed: () { mostroRepo.cancelOrder(orderId); - if (message?.action == action.Action.takeBuy) { - final controller = ref.read( - takeBuyOrderNotifierProvider(orderId).notifier); - controller.dispose(); - } else if (message?.action == action.Action.takeSell) { - final controller = ref.read( - takeSellOrderNotifierProvider(orderId).notifier); - controller.dispose(); + try { + if (message == null) { + context.go('/'); + return; + } + + // Dispose notifier first + switch (message.action) { + case action.Action.takeBuy: + ref.read(takeBuyOrderNotifierProvider(orderId).notifier).dispose(); + break; + case action.Action.takeSell: + ref.read(takeSellOrderNotifierProvider(orderId).notifier).dispose(); + break; + default: + // Log unexpected action type + break; + } + + // Then cancel order + mostroRepo.cancelOrder(orderId); + context.go('/'); + } catch (e) { + // Show error to user + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to cancel order: ${e.toString()}')), + ); } context.go('/'); }, From be21a2ddc1c570c5a66a819e85c8f1314f25e233 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 11 Jan 2025 10:34:34 -0300 Subject: [PATCH 024/149] added localization extension for retrieving localized strings based on Action type and AbstractOrderNotifier to consolidate orders --- .../notfiers/abstract_order_notifier.dart | 85 ++++++++++ lib/generated/action_localizations.dart | 104 ++++++++++++ lib/generated/intl/messages_en.dart | 79 +++++---- lib/generated/l10n.dart | 154 +++++++++--------- lib/l10n/intl_en.arb | 86 +++++----- 5 files changed, 348 insertions(+), 160 deletions(-) create mode 100644 lib/features/order/notfiers/abstract_order_notifier.dart create mode 100644 lib/generated/action_localizations.dart diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart new file mode 100644 index 00000000..2d8de151 --- /dev/null +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -0,0 +1,85 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; +import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; +import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; + +class AbstractOrderNotifier extends StateNotifier { + final MostroRepository orderRepository; + final Ref ref; + final String orderId; + StreamSubscription? _orderSubscription; + + AbstractOrderNotifier({ + required this.orderRepository, + required this.orderId, + required this.ref, + }) : super(orderRepository.getOrderById(orderId) ?? + MostroMessage(action: Action.notFound, id: orderId)) { + subscribe(); + } + + Future subscribe(MostroMessage message) async { + try { + final stream = await orderRepository.publishOrder(message); + _orderSubscription = stream.listen((order) { + state = order; + handleOrderUpdate(); + }); + } catch (e) { + handleError(e); + } + } + + void handleError(Object err) { + ref.read(notificationProvider.notifier).showInformation(err.toString()); + } + + void handleOrderUpdate() { + final navProvider = ref.read(navigationProvider.notifier); + final notifProvider = ref.read(notificationProvider.notifier); + + switch (state.action) { + case Action.newOrder: + navProvider.go('/order_confirmed/${state.id!}'); + break; + case Action.payInvoice: + navProvider.go('/pay_invoice/${state.id!}'); + break; + case Action.outOfRangeSatsAmount: + notifProvider.showInformation('Sats out of range'); + break; + case Action.outOfRangeFiatAmount: + notifProvider.showInformation('Fiant amount out of range'); + break; + case Action.waitingSellerToPay: + notifProvider.showInformation('Waiting Seller to pay'); + break; + case Action.waitingBuyerInvoice: + notifProvider.showInformation('Waiting Buy Invoice'); + break; + case Action.buyerTookOrder: + notifProvider.showInformation('Buyer took order'); + break; + case Action.fiatSentOk: + case Action.holdInvoicePaymentSettled: + case Action.rate: + case Action.rateReceived: + case Action.canceled: + case Action.cooperativeCancelInitiatedByYou: + case Action.disputeInitiatedByYou: + case Action.adminSettled: + default: + notifProvider.showInformation(state.action.toString()); + break; + } + } + + @override + void dispose() { + _orderSubscription?.cancel(); + super.dispose(); + } +} diff --git a/lib/generated/action_localizations.dart b/lib/generated/action_localizations.dart new file mode 100644 index 00000000..db3b239d --- /dev/null +++ b/lib/generated/action_localizations.dart @@ -0,0 +1,104 @@ +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; + +extension ActionLocalizationX on S { + String actionLabel(Action action, + {Map placeholders = const {}}) { + switch (action) { + case Action.newOrder: + return newOrder(placeholders['expiration_hours'] ?? 24); + case Action.payInvoice: + return payInvoice(placeholders['amount'], placeholders['fiat_code'], + placeholders['fiat_amount'], placeholders['expiration_seconds']); + case Action.fiatSentOk: + if (placeholders['seller_npub']) { + return fiatSentOkBuyer(placeholders['seller_npub']); + } else { + return fiatSentOkSeller(placeholders['buyer_npub']); + } + case Action.released: + return released(placeholders['seller_npub']); + case Action.canceled: + return canceled(placeholders['id']); + case Action.cooperativeCancelInitiatedByYou: + return cooperativeCancelInitiatedByYou(placeholders['id']); + case Action.cooperativeCancelInitiatedByPeer: + return cooperativeCancelInitiatedByPeer(placeholders['id']); + case Action.disputeInitiatedByYou: + return disputeInitiatedByYou( + placeholders['id'], placeholders['user_token']); + case Action.disputeInitiatedByPeer: + return disputeInitiatedByPeer( + placeholders['id'], placeholders['user_token']); + case Action.cooperativeCancelAccepted: + return cooperativeCancelAccepted(placeholders['id']); + case Action.buyerInvoiceAccepted: + return buyerInvoiceAccepted; + case Action.purchaseCompleted: + return purchaseCompleted; + case Action.holdInvoicePaymentAccepted: + return holdInvoicePaymentAccepted( + placeholders['seller_npub'], + placeholders['id'], + placeholders['fiat_code'], + placeholders['fiat_amount'], + placeholders['payment_method']); + case Action.holdInvoicePaymentSettled: + return holdInvoicePaymentSettled(placeholders['buyer_npub']); + case Action.holdInvoicePaymentCanceled: + return holdInvoicePaymentCanceled; + case Action.waitingSellerToPay: + return waitingSellerToPay( + placeholders['id'], placeholders['expiration_seconds']); + case Action.waitingBuyerInvoice: + return waitingBuyerInvoice((placeholders['expiration_seconds'])); + case Action.addInvoice: + return addInvoice(placeholders['amount'], placeholders['fiat_code'], + placeholders['fiat_amount'], placeholders['expiration_seconds']); + case Action.buyerTookOrder: + return buyerTookOrder( + placeholders['buyer_npub'], + placeholders['fiat_code'], + placeholders['fiat_amount'], + placeholders['payment_method']); + case Action.rate: + return rate; + case Action.rateReceived: + return rateReceived; + case Action.cantDo: + return cantDo(placeholders['action']); + case Action.adminCanceled: + return this.adminCanceled; + case Action.adminSettled: + return this.adminSettled; + case Action.adminAddSolver: + return adminAddSolver(placeholders['npub']); + case Action.adminTookDispute: + return this.adminTookDispute; + case Action.isNotYourOrder: + return isNotYourOrder(placeholders['action']); + case Action.notAllowedByStatus: + return notAllowedByStatus(placeholders['action'], placeholders['id'], + placeholders['order_status']); + case Action.outOfRangeFiatAmount: + return outOfRangeFiatAmount( + placeholders['min_amount'], placeholders['max_amount']); + case Action.isNotYourDispute: + return isNotYourDispute; + case Action.notFound: + return notFound; + case Action.incorrectInvoiceAmount: + return this.incorrectInvoiceAmount; + case Action.invalidSatsAmount: + return invalidSatsAmount; + case Action.outOfRangeSatsAmount: + return outOfRangeSatsAmount( + placeholders['min_order_amount'], placeholders['max_order_amount']); + case Action.paymentFailed: + return paymentFailed(placeholders['payment_attempts'], + placeholders['payment_retries_interval']); + case Action.invoiceUpdated: + return invoiceUpdated; + } + } +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 2dda54e3..b8594fd7 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -108,56 +108,55 @@ class MessageLookup extends MessageLookupByLibrary { 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( + "addInvoice": m0, + "adminAddSolver": m1, + "adminCanceledAdmin": m2, + "adminCanceledUsers": m3, + "adminSettledAdmin": m4, + "adminSettledUsers": m5, + "adminTookDisputeAdmin": m6, + "adminTookDisputeUsers": m7, + "buyerInvoiceAccepted": MessageLookupByLibrary.simpleMessage( "Invoice has been successfully saved!"), - "buyer_took_order": m8, + "buyerTookOrder": 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( + "cantDo": m10, + "cooperativeCancelAccepted": m11, + "cooperativeCancelInitiatedByPeer": m12, + "cooperativeCancelInitiatedByYou": m13, + "disputeInitiatedByPeer": m14, + "disputeInitiatedByYou": m15, + "fiatSentOkBuyer": m16, + "fiatSentOkSeller": m17, + "holdInvoicePaymentAccepted": m18, + "holdInvoicePaymentCanceled": 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( + "holdInvoicePaymentSettled": m19, + "incorrectInvoiceAmountBuyerAddInvoice": m20, + "incorrectInvoiceAmountBuyerNewOrder": 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."), + "invalidSatsAmount": MessageLookupByLibrary.simpleMessage( "The specified Sats amount is invalid."), - "invoice_updated": MessageLookupByLibrary.simpleMessage( + "invoiceUpdated": MessageLookupByLibrary.simpleMessage( "Invoice has been successfully updated!"), - "is_not_your_dispute": MessageLookupByLibrary.simpleMessage( + "isNotYourDispute": 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( + "isNotYourOrder": m21, + "newOrder": m22, + "notAllowedByStatus": m23, + "notFound": MessageLookupByLibrary.simpleMessage("Dispute not found."), + "outOfRangeFiatAmount": m24, + "outOfRangeSatsAmount": m25, + "payInvoice": m26, + "paymentFailed": m27, + "purchaseCompleted": 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": + "rateReceived": MessageLookupByLibrary.simpleMessage("Rating successfully saved!"), "released": m28, - "waiting_buyer_invoice": m29, - "waiting_seller_to_pay": m30 + "waitingBuyerInvoice": m29, + "waitingSellerToPay": m30 }; } diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 10d8c509..5be88bcc 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -51,10 +51,10 @@ class 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) { + String newOrder(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', + name: 'newOrder', desc: '', args: [expiration_hours], ); @@ -71,94 +71,94 @@ class S { } /// `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, + String payInvoice(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', + name: 'payInvoice', 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, + String addInvoice(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', + name: 'addInvoice', 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) { + String waitingSellerToPay(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', + name: 'waitingSellerToPay', 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) { + String waitingBuyerInvoice(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', + name: 'waitingBuyerInvoice', desc: '', args: [expiration_seconds], ); } /// `Invoice has been successfully saved!` - String get buyer_invoice_accepted { + String get buyerInvoiceAccepted { return Intl.message( 'Invoice has been successfully saved!', - name: 'buyer_invoice_accepted', + name: 'buyerInvoiceAccepted', 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, + String holdInvoicePaymentAccepted(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', + name: 'holdInvoicePaymentAccepted', 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) { + String buyerTookOrder(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', + name: 'buyerTookOrder', 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) { + String fiatSentOkBuyer(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', + name: 'fiatSentOkBuyer', 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) { + String fiatSentOkSeller(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', + name: 'fiatSentOkSeller', desc: '', args: [buyer_npub], ); @@ -175,20 +175,20 @@ class S { } /// `Your satoshis purchase has been completed successfully. I have paid your invoice, enjoy sound money!` - String get purchase_completed { + String get purchaseCompleted { return Intl.message( 'Your satoshis purchase has been completed successfully. I have paid your invoice, enjoy sound money!', - name: 'purchase_completed', + name: 'purchaseCompleted', desc: '', args: [], ); } /// `Your Sats sale has been completed after confirming the payment from {buyer_npub}.` - String hold_invoice_payment_settled(Object buyer_npub) { + String holdInvoicePaymentSettled(Object buyer_npub) { return Intl.message( 'Your Sats sale has been completed after confirming the payment from $buyer_npub.', - name: 'hold_invoice_payment_settled', + name: 'holdInvoicePaymentSettled', desc: '', args: [buyer_npub], ); @@ -205,262 +205,262 @@ class S { } /// `Rating successfully saved!` - String get rate_received { + String get rateReceived { return Intl.message( 'Rating successfully saved!', - name: 'rate_received', + name: 'rateReceived', 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) { + String cooperativeCancelInitiatedByYou(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', + name: 'cooperativeCancelInitiatedByYou', 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) { + String cooperativeCancelInitiatedByPeer(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', + name: 'cooperativeCancelInitiatedByPeer', desc: '', args: [id], ); } /// `Order {id} has been successfully cancelled!` - String cooperative_cancel_accepted(Object id) { + String cooperativeCancelAccepted(Object id) { return Intl.message( 'Order $id has been successfully cancelled!', - name: 'cooperative_cancel_accepted', + name: 'cooperativeCancelAccepted', 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) { + String disputeInitiatedByYou(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', + name: 'disputeInitiatedByYou', 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) { + String disputeInitiatedByPeer(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', + name: 'disputeInitiatedByPeer', 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) { + String adminTookDisputeAdmin(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', + name: 'adminTookDisputeAdmin', 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) { + String adminTookDisputeUsers(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', + name: 'adminTookDisputeUsers', desc: '', args: [admin_npub], ); } /// `You have cancelled the order ID: {id}!` - String admin_canceled_admin(Object id) { + String adminCanceledAdmin(Object id) { return Intl.message( 'You have cancelled the order ID: $id!', - name: 'admin_canceled_admin', + name: 'adminCanceledAdmin', desc: '', args: [id], ); } /// `Admin has cancelled the order ID: {id}!` - String admin_canceled_users(Object id) { + String adminCanceledUsers(Object id) { return Intl.message( 'Admin has cancelled the order ID: $id!', - name: 'admin_canceled_users', + name: 'adminCanceledUsers', desc: '', args: [id], ); } /// `You have completed the order ID: {id}!` - String admin_settled_admin(Object id) { + String adminSettledAdmin(Object id) { return Intl.message( 'You have completed the order ID: $id!', - name: 'admin_settled_admin', + name: 'adminSettledAdmin', desc: '', args: [id], ); } /// `Admin has completed the order ID: {id}!` - String admin_settled_users(Object id) { + String adminSettledUsers(Object id) { return Intl.message( 'Admin has completed the order ID: $id!', - name: 'admin_settled_users', + name: 'adminSettledUsers', desc: '', args: [id], ); } /// `This dispute was not assigned to you!` - String get is_not_your_dispute { + String get isNotYourDispute { return Intl.message( 'This dispute was not assigned to you!', - name: 'is_not_your_dispute', + name: 'isNotYourDispute', desc: '', args: [], ); } /// `Dispute not found.` - String get not_found { + String get notFound { return Intl.message( 'Dispute not found.', - name: 'not_found', + name: 'notFound', 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( + String paymentFailed( 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', + name: 'paymentFailed', desc: '', args: [payment_attempts, payment_retries_interval], ); } /// `Invoice has been successfully updated!` - String get invoice_updated { + String get invoiceUpdated { return Intl.message( 'Invoice has been successfully updated!', - name: 'invoice_updated', + name: 'invoiceUpdated', desc: '', args: [], ); } /// `The invoice was cancelled; your Sats will be available in your wallet again.` - String get hold_invoice_payment_canceled { + String get holdInvoicePaymentCanceled { return Intl.message( 'The invoice was cancelled; your Sats will be available in your wallet again.', - name: 'hold_invoice_payment_canceled', + name: 'holdInvoicePaymentCanceled', desc: '', args: [], ); } /// `You are not allowed to {action} for this order!` - String cant_do(Object action) { + String cantDo(Object action) { return Intl.message( 'You are not allowed to $action for this order!', - name: 'cant_do', + name: 'cantDo', desc: '', args: [action], ); } /// `You have successfully added the solver {npub}.` - String admin_add_solver(Object npub) { + String adminAddSolver(Object npub) { return Intl.message( 'You have successfully added the solver $npub.', - name: 'admin_add_solver', + name: 'adminAddSolver', desc: '', args: [npub], ); } /// `You did not create this order and are not authorized to {action} it.` - String is_not_your_order(Object action) { + String isNotYourOrder(Object action) { return Intl.message( 'You did not create this order and are not authorized to $action it.', - name: 'is_not_your_order', + name: 'isNotYourOrder', 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) { + String notAllowedByStatus(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', + name: 'notAllowedByStatus', 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) { + String outOfRangeFiatAmount(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', + name: 'outOfRangeFiatAmount', 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 { + String get incorrectInvoiceAmountBuyerNewOrder { 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', + name: 'incorrectInvoiceAmountBuyerNewOrder', 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) { + String incorrectInvoiceAmountBuyerAddInvoice(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', + name: 'incorrectInvoiceAmountBuyerAddInvoice', desc: '', args: [amount], ); } /// `The specified Sats amount is invalid.` - String get invalid_sats_amount { + String get invalidSatsAmount { return Intl.message( 'The specified Sats amount is invalid.', - name: 'invalid_sats_amount', + name: 'invalidSatsAmount', 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( + String outOfRangeSatsAmount( 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', + name: 'outOfRangeSatsAmount', desc: '', args: [min_order_amount, max_order_amount], ); diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index d4a9a072..e1ff2efc 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1,44 +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." -} + "@@locale": "en", + "newOrder": "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}!", + "payInvoice": "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.", + "addInvoice": "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.", + "waitingSellerToPay": "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.", + "waitingBuyerInvoice": "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.", + "buyerInvoiceAccepted": "Invoice has been successfully saved!", + "holdInvoicePaymentAccepted": "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.", + "buyerTookOrder": "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.", + "fiatSentOkBuyer": "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.", + "fiatSentOkSeller": "{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.", + "purchaseCompleted": "Your satoshis purchase has been completed successfully. I have paid your invoice, enjoy sound money!", + "holdInvoicePaymentSettled": "Your Sats sale has been completed after confirming the payment from {buyer_npub}.", + "rate": "Please rate your counterparty", + "rateReceived": "Rating successfully saved!", + "cooperativeCancelInitiatedByYou": "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.", + "cooperativeCancelInitiatedByPeer": "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.", + "cooperativeCancelAccepted": "Order {id} has been successfully cancelled!", + "disputeInitiatedByYou": "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}.", + "disputeInitiatedByPeer": "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}.", + "adminTookDisputeAdmin": "Here are the details of the dispute order you have taken: {details}. You need to determine which user is correct and decide whether to cancel or complete the order. Please note that your decision will be final and cannot be reversed.", + "adminTookDisputeUsers": "The solver {admin_npub} will handle your dispute. You can contact them directly, but if they reach out to you first, make sure to ask them for your dispute token.", + "adminCanceledAdmin": "You have cancelled the order ID: {id}!", + "adminCanceledUsers": "Admin has cancelled the order ID: {id}!", + "adminSettledAdmin": "You have completed the order ID: {id}!", + "adminSettledUsers": "Admin has completed the order ID: {id}!", + "isNotYourDispute": "This dispute was not assigned to you!", + "notFound": "Dispute not found.", + "paymentFailed": "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.", + "invoiceUpdated": "Invoice has been successfully updated!", + "holdInvoicePaymentCanceled": "The invoice was cancelled; your Sats will be available in your wallet again.", + "cantDo": "You are not allowed to {action} for this order!", + "adminAddSolver": "You have successfully added the solver {npub}.", + "isNotYourOrder": "You did not create this order and are not authorized to {action} it.", + "notAllowedByStatus": "You are not allowed to {action} because order Id {id} status is {order_status}.", + "outOfRangeFiatAmount": "The requested amount is incorrect and may be outside the acceptable range. The minimum is {min_amount} and the maximum is {max_amount}.", + "incorrectInvoiceAmountBuyerNewOrder": "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.", + "incorrectInvoiceAmountBuyerAddInvoice": "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.", + "invalidSatsAmount": "The specified Sats amount is invalid.", + "outOfRangeSatsAmount": "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." + } \ No newline at end of file From ab1b7c300d7224b9438481a23fe6e335684ec84e Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 11 Jan 2025 17:05:14 -0300 Subject: [PATCH 025/149] Consolidate all OrderNotifiers as subclasses of AbstractOrderNotifier --- .../notifiers/add_order_notifier.dart | 75 ++--------------- .../screens/order_confirmation_screen.dart | 2 +- .../notfiers/abstract_order_notifier.dart | 36 ++++---- .../order/notfiers/order_notifier.dart | 82 ++----------------- .../providers/order_notifier_provider.dart | 11 +-- .../notifiers/take_buy_order_notifier.dart | 58 ++----------- .../notifiers/take_sell_order_notifier.dart | 81 ++---------------- lib/generated/action_localizations.dart | 26 +++++- .../notifiers/notification_notifier.dart | 13 ++- .../widgets/notification_listener_widget.dart | 4 +- 10 files changed, 84 insertions(+), 304 deletions(-) diff --git a/lib/features/add_order/notifiers/add_order_notifier.dart b/lib/features/add_order/notifiers/add_order_notifier.dart index c2dcc93c..0b7c8ff2 100644 --- a/lib/features/add_order/notifiers/add_order_notifier.dart +++ b/lib/features/add_order/notifiers/add_order_notifier.dart @@ -5,17 +5,13 @@ 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'; import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; -import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; -import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; -class AddOrderNotifier extends StateNotifier { - final MostroRepository _orderRepository; - final Ref ref; +class AddOrderNotifier extends AbstractOrderNotifier { final String uuid; - StreamSubscription? _orderSubscription; - AddOrderNotifier(this._orderRepository, this.uuid, this.ref) - : super(MostroMessage(action: Action.newOrder)); + AddOrderNotifier(MostroRepository orderRepository, this.uuid, Ref ref) + : super(orderRepository, uuid, ref); Future submitOrder(String fiatCode, int fiatAmount, int satsAmount, String paymentMethod, OrderType orderType, @@ -29,66 +25,7 @@ class AddOrderNotifier extends StateNotifier { ); final message = MostroMessage(action: Action.newOrder, id: null, payload: order); - - try { - final stream = await _orderRepository.publishOrder(message); - _orderSubscription = stream.listen((order) { - state = order; - _handleOrderUpdate(); - }); - } catch (e) { - _handleError(e); - } - } - - void _handleError(Object err) { - ref.read(notificationProvider.notifier).showInformation(err.toString()); - } - - void _handleOrderUpdate() { - final navProvider = ref.read(navigationProvider.notifier); - final notifProvider = ref.read(notificationProvider.notifier); - - switch (state.action) { - case Action.newOrder: - navProvider.go('/order_confirmed/${state.id!}'); - break; - case Action.payInvoice: - navProvider.go('/pay_invoice/${state.id!}'); - break; - case Action.outOfRangeSatsAmount: - notifProvider.showInformation('Sats out of range'); - break; - case Action.outOfRangeFiatAmount: - notifProvider.showInformation('Fiant amount out of range'); - break; - case Action.waitingSellerToPay: - notifProvider.showInformation('Waiting Seller to pay'); - break; - case Action.waitingBuyerInvoice: - notifProvider.showInformation('Waiting Buy Invoice'); - break; - case Action.buyerTookOrder: - notifProvider.showInformation('Buyer took order'); - break; - case Action.fiatSentOk: - case Action.holdInvoicePaymentSettled: - case Action.rate: - case Action.rateReceived: - case Action.canceled: - case Action.cooperativeCancelInitiatedByYou: - case Action.disputeInitiatedByYou: - case Action.adminSettled: - default: - notifProvider.showInformation(state.action.toString()); - break; - } - } - - @override - void dispose() { - _orderSubscription?.cancel(); - print('Disposed!'); - super.dispose(); + final stream = await orderRepository.publishOrder(message); + await subscribe(stream); } } diff --git a/lib/features/add_order/screens/order_confirmation_screen.dart b/lib/features/add_order/screens/order_confirmation_screen.dart index 081dad9d..a9622c87 100644 --- a/lib/features/add_order/screens/order_confirmation_screen.dart +++ b/lib/features/add_order/screens/order_confirmation_screen.dart @@ -25,7 +25,7 @@ class OrderConfirmationScreen extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - S.of(context).new_order('24'), + S.of(context).newOrder('24'), style: TextStyle(fontSize: 18, color: AppTheme.cream1), textAlign: TextAlign.center, ), diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 2d8de151..53df7c87 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; @@ -12,18 +13,15 @@ class AbstractOrderNotifier extends StateNotifier { final String orderId; StreamSubscription? _orderSubscription; - AbstractOrderNotifier({ - required this.orderRepository, - required this.orderId, - required this.ref, - }) : super(orderRepository.getOrderById(orderId) ?? - MostroMessage(action: Action.notFound, id: orderId)) { - subscribe(); - } + AbstractOrderNotifier( + this.orderRepository, + this.orderId, + this.ref, + ) : super(orderRepository.getOrderById(orderId) ?? + MostroMessage(action: Action.notFound, id: orderId)); - Future subscribe(MostroMessage message) async { + Future subscribe(Stream stream) async { try { - final stream = await orderRepository.publishOrder(message); _orderSubscription = stream.listen((order) { state = order; handleOrderUpdate(); @@ -34,7 +32,7 @@ class AbstractOrderNotifier extends StateNotifier { } void handleError(Object err) { - ref.read(notificationProvider.notifier).showInformation(err.toString()); + //ref.read(notificationProvider.notifier).showInformation(err.toString()); } void handleOrderUpdate() { @@ -49,19 +47,21 @@ class AbstractOrderNotifier extends StateNotifier { navProvider.go('/pay_invoice/${state.id!}'); break; case Action.outOfRangeSatsAmount: - notifProvider.showInformation('Sats out of range'); - break; case Action.outOfRangeFiatAmount: - notifProvider.showInformation('Fiant amount out of range'); + final order = state.payload! as Order; + notifProvider.showInformation(state.action, values: { + 'min_order_amount': order.minAmount, + 'max_order_amount': order.maxAmount + }); break; case Action.waitingSellerToPay: - notifProvider.showInformation('Waiting Seller to pay'); + notifProvider.showInformation(state.action, values: {'id': state.id}); break; case Action.waitingBuyerInvoice: - notifProvider.showInformation('Waiting Buy Invoice'); + notifProvider.showInformation(state.action); break; case Action.buyerTookOrder: - notifProvider.showInformation('Buyer took order'); + notifProvider.showInformation(state.action); break; case Action.fiatSentOk: case Action.holdInvoicePaymentSettled: @@ -72,7 +72,7 @@ class AbstractOrderNotifier extends StateNotifier { case Action.disputeInitiatedByYou: case Action.adminSettled: default: - notifProvider.showInformation(state.action.toString()); + notifProvider.showInformation(state.action); break; } } diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 4722665e..06d0f4bf 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -1,88 +1,18 @@ import 'dart:async'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; -import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; -import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; -class OrderNotifier extends StateNotifier { - final MostroRepository orderRepository; - final Ref ref; - final String orderId; - StreamSubscription? _orderSubscription; - - OrderNotifier({ - required this.orderRepository, - required this.orderId, - required this.ref, - }) : super(orderRepository.getOrderById(orderId) ?? - MostroMessage(action: Action.notFound, id: orderId)) { - subscribe(); +class OrderNotifier extends AbstractOrderNotifier { + OrderNotifier(super.orderRepository, super.orderId, super.ref) { + _reSubscribe(); } - Future subscribe() async { + Future _reSubscribe() async { final existingMessage = orderRepository.getOrderById(orderId); if (existingMessage == null) { print('Order $orderId not found in repository; subscription aborted.'); return; } final stream = orderRepository.resubscribeOrder(orderId); - _orderSubscription = stream.listen((msg) { - state = msg; - _handleOrderUpdate(); - }, onError: (err) { - _handleError(err); - }); - } - - void _handleError(Object err) { - ref.read(notificationProvider.notifier).showInformation(err.toString()); - } - - void _handleOrderUpdate() { - final navProvider = ref.read(navigationProvider.notifier); - final notifProvider = ref.read(notificationProvider.notifier); - - switch (state.action) { - case Action.newOrder: - navProvider.go('/order_confirmed/${state.id!}'); - break; - case Action.payInvoice: - navProvider.go('/pay_invoice/${state.id!}'); - break; - case Action.outOfRangeSatsAmount: - notifProvider.showInformation('Sats out of range'); - break; - case Action.outOfRangeFiatAmount: - notifProvider.showInformation('Fiant amount out of range'); - break; - case Action.waitingSellerToPay: - notifProvider.showInformation('Waiting Seller to pay'); - break; - case Action.waitingBuyerInvoice: - notifProvider.showInformation('Waiting Buy Invoice'); - break; - case Action.buyerTookOrder: - notifProvider.showInformation('Buyer took order'); - break; - case Action.fiatSentOk: - case Action.holdInvoicePaymentSettled: - case Action.rate: - case Action.rateReceived: - case Action.canceled: - case Action.cooperativeCancelInitiatedByYou: - case Action.disputeInitiatedByYou: - case Action.adminSettled: - default: - notifProvider.showInformation(state.action.toString()); - break; - } - } - - @override - void dispose() { - _orderSubscription?.cancel(); - super.dispose(); + await subscribe(stream); } } diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index ca1bd361..534c9d6d 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -3,13 +3,14 @@ import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; -final orderNotifierProvider = StateNotifierProvider.family( +final orderNotifierProvider = + StateNotifierProvider.family( (ref, orderId) { final repo = ref.watch(mostroRepositoryProvider); return OrderNotifier( - orderRepository: repo, - orderId: orderId, - ref: ref, + repo, + orderId, + ref, ); }, -); \ No newline at end of file +); diff --git a/lib/features/take_order/notifiers/take_buy_order_notifier.dart b/lib/features/take_order/notifiers/take_buy_order_notifier.dart index 8f5c9ca2..5b47de99 100644 --- a/lib/features/take_order/notifiers/take_buy_order_notifier.dart +++ b/lib/features/take_order/notifiers/take_buy_order_notifier.dart @@ -1,60 +1,12 @@ -import 'dart:async'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; -import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; -import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; -class TakeBuyOrderNotifier extends StateNotifier { - final MostroRepository _orderRepository; - final String orderId; - final Ref ref; - StreamSubscription? _orderSubscription; +class TakeBuyOrderNotifier extends AbstractOrderNotifier { - TakeBuyOrderNotifier(this._orderRepository, this.orderId, this.ref) - : super(MostroMessage(action: Action.takeBuy)); + TakeBuyOrderNotifier(super.orderRepository, super.orderId, super.ref); void takeBuyOrder(String orderId, int? amount) async { - try { - final stream = await _orderRepository.takeBuyOrder(orderId, amount); - _orderSubscription = stream.listen((order) { - state = order; - _handleOrderUpdate(); - }); - } catch (e) { - _handleError(e); - } + final stream = await orderRepository.takeBuyOrder(orderId, amount); + await subscribe(stream); } - void _handleError(Object err) { - ref.read(notificationProvider.notifier).showInformation(err.toString()); - } - - void _handleOrderUpdate() { - final notifProvider = ref.read(notificationProvider.notifier); - - switch (state.action) { - case Action.payInvoice: - ref.read(navigationProvider.notifier).go('/pay_invoice/${state.id!}'); - break; - case Action.waitingBuyerInvoice: - notifProvider.showInformation('Waiting Buy Invoice'); - break; - case Action.waitingSellerToPay: - notifProvider.showInformation('Waiting for Seller to pay'); - break; - case Action.rate: - case Action.rateReceived: - default: - notifProvider.showInformation(state.action.toString()); - break; - } - } - - @override - void dispose() { - _orderSubscription?.cancel(); - super.dispose(); - } } diff --git a/lib/features/take_order/notifiers/take_sell_order_notifier.dart b/lib/features/take_order/notifiers/take_sell_order_notifier.dart index 2d46633b..6d02badd 100644 --- a/lib/features/take_order/notifiers/take_sell_order_notifier.dart +++ b/lib/features/take_order/notifiers/take_sell_order_notifier.dart @@ -1,84 +1,19 @@ -import 'dart:async'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; -import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; -import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; -class TakeSellOrderNotifier extends StateNotifier { - final MostroRepository _orderRepository; - final String orderId; - final Ref ref; - StreamSubscription? _orderSubscription; - - TakeSellOrderNotifier(this._orderRepository, this.orderId, this.ref) - : super(MostroMessage(action: Action.takeSell)); +class TakeSellOrderNotifier extends AbstractOrderNotifier { + TakeSellOrderNotifier(super.orderRepository, super.orderId, super.ref); void takeSellOrder(String orderId, int? amount, String? lnAddress) async { - try { - final stream = - await _orderRepository.takeSellOrder(orderId, amount, lnAddress); - _orderSubscription = stream.listen((order) { - state = order; - _handleOrderUpdate(); - }); - } catch (e) { - _handleError(e); - } - } - - void _handleError(Object err) { - ref.read(notificationProvider.notifier).showInformation(err.toString()); + final stream = + await orderRepository.takeSellOrder(orderId, amount, lnAddress); + await subscribe(stream); } void sendInvoice(String orderId, String invoice, int? amount) async { - await _orderRepository.sendInvoice(orderId, invoice); - } - - void _handleOrderUpdate() { - final navProvider = ref.read(navigationProvider.notifier); - final notifProvider = ref.read(notificationProvider.notifier); - switch (state.action) { - case Action.addInvoice: - navProvider.go('/add_invoice/$orderId'); - break; - case Action.waitingSellerToPay: - navProvider.go('/'); - notifProvider.showInformation('Waiting for Seller to pay'); - case Action.incorrectInvoiceAmount: - notifProvider.showInformation('Incorrect Invoice Amount'); - break; - case Action.outOfRangeFiatAmount: - case Action.outOfRangeSatsAmount: - break; - case Action.holdInvoicePaymentAccepted: - break; - case Action.fiatSentOk: - break; - case Action.released: - break; - case Action.purchaseCompleted: - break; - case Action.rate: - break; - case Action.cooperativeCancelInitiatedByPeer: - case Action.disputeInitiatedByPeer: - case Action.adminSettled: - default: - notifProvider.showInformation(state.action.toString()); - break; - } + await orderRepository.sendInvoice(orderId, invoice); } void cancelOrder() { - dispose(); - } - - @override - void dispose() { - _orderSubscription?.cancel(); - super.dispose(); + orderRepository.cancelOrder(orderId); } } diff --git a/lib/generated/action_localizations.dart b/lib/generated/action_localizations.dart index db3b239d..2aa2a2ce 100644 --- a/lib/generated/action_localizations.dart +++ b/lib/generated/action_localizations.dart @@ -68,13 +68,25 @@ extension ActionLocalizationX on S { case Action.cantDo: return cantDo(placeholders['action']); case Action.adminCanceled: - return this.adminCanceled; + if (placeholders['admin']) { + return adminCanceledAdmin(placeholders['id']); + } else { + return adminCanceledUsers(placeholders['id']); + } case Action.adminSettled: - return this.adminSettled; + if (placeholders['admin']) { + return adminSettledAdmin(placeholders['id']); + } else { + return adminSettledUsers(placeholders['id']); + } case Action.adminAddSolver: return adminAddSolver(placeholders['npub']); case Action.adminTookDispute: - return this.adminTookDispute; + if (placeholders['details']) { + return adminTookDisputeAdmin(placeholders['details']); + } else { + return adminTookDisputeUsers(placeholders['admin_npub']); + } case Action.isNotYourOrder: return isNotYourOrder(placeholders['action']); case Action.notAllowedByStatus: @@ -88,7 +100,11 @@ extension ActionLocalizationX on S { case Action.notFound: return notFound; case Action.incorrectInvoiceAmount: - return this.incorrectInvoiceAmount; + if (placeholders['amount']) { + return incorrectInvoiceAmountBuyerAddInvoice(placeholders['amount']); + } else { + return incorrectInvoiceAmountBuyerNewOrder; + } case Action.invalidSatsAmount: return invalidSatsAmount; case Action.outOfRangeSatsAmount: @@ -99,6 +115,8 @@ extension ActionLocalizationX on S { placeholders['payment_retries_interval']); case Action.invoiceUpdated: return invoiceUpdated; + default: + return 'Localization for Action $action not found'; } } } diff --git a/lib/shared/notifiers/notification_notifier.dart b/lib/shared/notifiers/notification_notifier.dart index 0327a637..5b370ae7 100644 --- a/lib/shared/notifiers/notification_notifier.dart +++ b/lib/shared/notifiers/notification_notifier.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; class NotificationState { - final String? message; + final actions.Action? action; + final Map placeholders; final WidgetBuilder? widgetBuilder; final bool informational; final bool actionRequired; NotificationState( - {this.message, + {this.action, + this.placeholders = const {}, this.widgetBuilder, this.informational = false, this.actionRequired = false}); @@ -17,8 +20,10 @@ class NotificationState { class NotificationNotifier extends StateNotifier { NotificationNotifier() : super(NotificationState()); - void showInformation(String message) { - state = NotificationState(message: message, informational: true); + void showInformation(actions.Action action, + {Map values = const {}}) { + state = NotificationState( + action: action, placeholders: values, informational: true); } void clearNotification() { diff --git a/lib/shared/widgets/notification_listener_widget.dart b/lib/shared/widgets/notification_listener_widget.dart index f1132236..6ce3aa41 100644 --- a/lib/shared/widgets/notification_listener_widget.dart +++ b/lib/shared/widgets/notification_listener_widget.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/generated/action_localizations.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/notifiers/notification_notifier.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; @@ -15,7 +17,7 @@ class NotificationListenerWidget extends ConsumerWidget { ref.listen(notificationProvider, (previous, next) { if (next.informational) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(next.message!)), + SnackBar(content: Text(S.of(context).actionLabel(next.action!, placeholders: next.placeholders))), ); // Clear notification after showing to prevent repetition ref.read(notificationProvider.notifier).clearNotification(); From b9888a3b9a53f082d9dcbcd4a10c336043d59412 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Tue, 14 Jan 2025 15:06:21 -0300 Subject: [PATCH 026/149] minor changes to order notifier and notification notifiers --- .../order/notfiers/abstract_order_notifier.dart | 8 +++++--- lib/shared/notifiers/notification_notifier.dart | 6 ++++++ .../widgets/notification_listener_widget.dart | 14 +++++++++----- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 53df7c87..e15d0e07 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -48,10 +48,10 @@ class AbstractOrderNotifier extends StateNotifier { break; case Action.outOfRangeSatsAmount: case Action.outOfRangeFiatAmount: - final order = state.payload! as Order; + final order = state.getPayload(); notifProvider.showInformation(state.action, values: { - 'min_order_amount': order.minAmount, - 'max_order_amount': order.maxAmount + 'min_order_amount': order?.minAmount, + 'max_order_amount': order?.maxAmount }); break; case Action.waitingSellerToPay: @@ -71,6 +71,8 @@ class AbstractOrderNotifier extends StateNotifier { case Action.cooperativeCancelInitiatedByYou: case Action.disputeInitiatedByYou: case Action.adminSettled: + notifProvider.showInformation(state.action); + break; default: notifProvider.showInformation(state.action); break; diff --git a/lib/shared/notifiers/notification_notifier.dart b/lib/shared/notifiers/notification_notifier.dart index 5b370ae7..19bf2a99 100644 --- a/lib/shared/notifiers/notification_notifier.dart +++ b/lib/shared/notifiers/notification_notifier.dart @@ -26,6 +26,12 @@ class NotificationNotifier extends StateNotifier { action: action, placeholders: values, informational: true); } + void showActionable(actions.Action action, + {Map values = const {}}) { + state = NotificationState( + action: action, placeholders: values, actionRequired: true); + } + void clearNotification() { state = NotificationState(); } diff --git a/lib/shared/widgets/notification_listener_widget.dart b/lib/shared/widgets/notification_listener_widget.dart index 6ce3aa41..6a8ef684 100644 --- a/lib/shared/widgets/notification_listener_widget.dart +++ b/lib/shared/widgets/notification_listener_widget.dart @@ -9,24 +9,28 @@ import 'package:mostro_mobile/shared/providers/notification_notifier_provider.da class NotificationListenerWidget extends ConsumerWidget { final Widget child; - const NotificationListenerWidget( - {super.key, required this.child}); + const NotificationListenerWidget({super.key, required this.child}); @override Widget build(BuildContext context, WidgetRef ref) { ref.listen(notificationProvider, (previous, next) { if (next.informational) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(S.of(context).actionLabel(next.action!, placeholders: next.placeholders))), + SnackBar( + content: Text(S + .of(context) + .actionLabel(next.action!, placeholders: next.placeholders))), ); // Clear notification after showing to prevent repetition ref.read(notificationProvider.notifier).clearNotification(); - } else if (next.actionRequired){ + } else if (next.actionRequired) { showDialog( context: context, builder: (context) => AlertDialog( title: Text('Action Required'), - content: Text('Please add your lightning invoice to proceed.'), + content: Text(S + .of(context) + .actionLabel(next.action!, placeholders: next.placeholders)), actions: [ TextButton( onPressed: () => context.go('/'), From 54f3b6af8e3504b14f112f67f18fafde04fdd970 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 18 Jan 2025 16:58:14 -0300 Subject: [PATCH 027/149] Filter user orders and updated Add Order screen to match web client --- .../add_order/screens/add_order_screen.dart | 238 +++++++++++++++--- .../widgets/fixed_switch_widget.dart | 48 ++++ .../home/notifiers/home_notifier.dart | 6 +- lib/presentation/widgets/bottom_nav_bar.dart | 20 +- .../widgets/currency_dropdown.dart | 16 +- 5 files changed, 280 insertions(+), 48 deletions(-) create mode 100644 lib/features/add_order/widgets/fixed_switch_widget.dart diff --git a/lib/features/add_order/screens/add_order_screen.dart b/lib/features/add_order/screens/add_order_screen.dart index f6c97706..19e679ad 100644 --- a/lib/features/add_order/screens/add_order_screen.dart +++ b/lib/features/add_order/screens/add_order_screen.dart @@ -7,23 +7,35 @@ import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/app/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/features/add_order/providers/add_order_notifier_provider.dart'; +import 'package:mostro_mobile/features/add_order/widgets/fixed_switch_widget.dart'; import 'package:mostro_mobile/presentation/widgets/currency_dropdown.dart'; import 'package:mostro_mobile/presentation/widgets/currency_text_field.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:uuid/uuid.dart'; -class AddOrderScreen extends ConsumerWidget { - final _formKey = GlobalKey(); +class AddOrderScreen extends ConsumerStatefulWidget { + const AddOrderScreen({super.key}); - AddOrderScreen({super.key}); + @override + ConsumerState createState() => _AddOrderScreenState(); +} + +class _AddOrderScreenState extends ConsumerState { + final _formKey = GlobalKey(); final _fiatAmountController = TextEditingController(); final _satsAmountController = TextEditingController(); final _paymentMethodController = TextEditingController(); final _lightningInvoiceController = TextEditingController(); + bool _marketRate = false; // false => Fixed, true => Market + double _premiumValue = 0.0; // slider for -10..10 + bool _isEnabled = false; // controls enabled or not + @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { + final orderType = ref.watch(orderTypeProvider); + return Scaffold( backgroundColor: AppTheme.dark1, appBar: AppBar( @@ -50,7 +62,7 @@ class AddOrderScreen extends ConsumerWidget { color: AppTheme.dark2, borderRadius: BorderRadius.circular(20), ), - child: _buildContent(context, ref), + child: _buildContent(context, ref, orderType), ), ), ], @@ -59,8 +71,7 @@ class AddOrderScreen extends ConsumerWidget { } Widget _buildContent( - BuildContext context, WidgetRef ref) { - final orderType = ref.watch(orderTypeProvider); + BuildContext context, WidgetRef ref, OrderType orderType) { return Form( key: _formKey, child: Column( @@ -128,6 +139,9 @@ class AddOrderScreen extends ConsumerWidget { ); } + /// + /// SELL FORM + /// Widget _buildSellForm(BuildContext context, WidgetRef ref) { return SingleChildScrollView( padding: const EdgeInsets.all(16.0), @@ -137,24 +151,77 @@ class AddOrderScreen extends ConsumerWidget { const Text('Make sure your order is below 20K sats', style: TextStyle(color: AppTheme.grey2)), const SizedBox(height: 16), - CurrencyDropdown(label: 'Fiat code'), + + // 1) Currency dropdown always enabled + CurrencyDropdown( + label: 'Fiat code', + onSelected: (String fiatCode) { + // Once a fiat code is selected, enable the other fields + setState(() { + _isEnabled = true; + }); + }, + ), + const SizedBox(height: 16), - CurrencyTextField( - controller: _fiatAmountController, label: 'Fiat amount'), + + // 2) fiat amount + _buildDisabledWrapper( + enabled: _isEnabled, + child: CurrencyTextField( + controller: _fiatAmountController, + label: 'Fiat amount', + ), + ), + const SizedBox(height: 16), - _buildFixedToggle(), + + // 3) fixed/market toggle + _buildDisabledWrapper( + enabled: _isEnabled, + child: FixedSwitch( + initialValue: _marketRate, + onChanged: (value) { + setState(() { + _marketRate = value; + }); + }, + ), + ), + const SizedBox(height: 16), - _buildTextField('Sats amount', _satsAmountController, - suffix: Icon(BitcoinIcons.satoshi_v1_outline).icon), + + // 4) either a text field for sats or a slider for premium + _marketRate + ? _buildDisabledWrapper( + enabled: _isEnabled, + child: _buildPremiumSlider(), + ) + : _buildDisabledWrapper( + enabled: _isEnabled, + child: _buildTextField('Sats amount', _satsAmountController, + suffix: Icon(BitcoinIcons.satoshi_v1_outline).icon), + ), + const SizedBox(height: 16), - _buildTextField('Payment method', _paymentMethodController), + + // 5) Payment method + _buildDisabledWrapper( + enabled: _isEnabled, + child: _buildTextField('Payment method', _paymentMethodController), + ), + const SizedBox(height: 32), + _buildActionButtons(context, ref, OrderType.sell), ], ), ); } + /// + /// BUY FORM + /// Widget _buildBuyForm(BuildContext context, WidgetRef ref) { return SingleChildScrollView( padding: const EdgeInsets.all(16.0), @@ -164,27 +231,90 @@ class AddOrderScreen extends ConsumerWidget { const Text('Make sure your order is below 20K sats', style: TextStyle(color: AppTheme.grey2)), const SizedBox(height: 16), - CurrencyDropdown(label: 'Fiat code'), - const SizedBox(height: 16), - CurrencyTextField( - controller: _fiatAmountController, label: 'Fiat amount'), + + // 1) Currency dropdown always enabled + CurrencyDropdown( + label: 'Fiat code', + onSelected: (String fiatCode) { + setState(() { + _isEnabled = true; + }); + }, + ), + const SizedBox(height: 16), - _buildFixedToggle(), + + // 2) fiat amount + _buildDisabledWrapper( + enabled: _isEnabled, + child: CurrencyTextField( + controller: _fiatAmountController, + label: 'Fiat amount', + ), + ), + const SizedBox(height: 16), - _buildTextField('Sats amount', _satsAmountController, - suffix: Icon(BitcoinIcons.satoshi_v1_outline).icon), + + // 3) fixed/market toggle + _buildDisabledWrapper( + enabled: _isEnabled, + child: FixedSwitch( + initialValue: _marketRate, + onChanged: (value) { + setState(() { + _marketRate = value; + }); + }, + ), + ), + const SizedBox(height: 16), - _buildTextField('Lightning Invoice without an amount', - _lightningInvoiceController), + + // 4) either text for sats or a slider for premium + if (_marketRate) + // MARKET: Show only premium slider + _buildDisabledWrapper( + enabled: _isEnabled, + child: _buildPremiumSlider(), + ) + else + // FIXED: Show Sats amount + LN Invoice fields + Column( + children: [ + _buildDisabledWrapper( + enabled: _isEnabled, + child: _buildTextField('Sats amount', _satsAmountController, + suffix: Icon(BitcoinIcons.satoshi_v1_outline).icon), + ), + const SizedBox(height: 16), + _buildDisabledWrapper( + enabled: _isEnabled, + child: _buildTextField( + 'Lightning Invoice without an amount', + _lightningInvoiceController, + ), + ), + ], + ), const SizedBox(height: 16), - _buildTextField('Payment method', _paymentMethodController), + + // 6) Payment method + _buildDisabledWrapper( + enabled: _isEnabled, + child: _buildTextField('Payment method', _paymentMethodController), + ), + const SizedBox(height: 32), + _buildActionButtons(context, ref, OrderType.buy), ], ), ); } + /// + /// REUSABLE TEXT FIELD + /// Widget _buildTextField(String label, TextEditingController controller, {IconData? suffix}) { return Container( @@ -213,19 +343,49 @@ class AddOrderScreen extends ConsumerWidget { ); } - Widget _buildFixedToggle() { - return Row( + /// + /// DISABLED WRAPPER + /// + /// If [enabled] is false, we show a grey overlay to indicate + /// it's disabled and the child can't be interacted with. + Widget _buildDisabledWrapper({required bool enabled, required Widget child}) { + return IgnorePointer( + ignoring: !enabled, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: enabled ? 1.0 : 0.4, + child: child, + ), + ); + } + + /// + /// PREMIUM SLIDER for -10..10 + /// + Widget _buildPremiumSlider() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Fixed', style: TextStyle(color: AppTheme.cream1)), - const SizedBox(width: 8), - Switch( - value: false, - onChanged: (value) {}, + const Text('Premium (%)', style: TextStyle(color: AppTheme.cream1)), + Slider( + value: _premiumValue, + min: -10, + max: 10, + divisions: 20, + label: _premiumValue.toStringAsFixed(1), + onChanged: (val) { + setState(() { + _premiumValue = val; + }); + }, ), ], ); } + /// + /// ACTION BUTTONS + /// Widget _buildActionButtons( BuildContext context, WidgetRef ref, OrderType orderType) { return Row( @@ -251,6 +411,9 @@ class AddOrderScreen extends ConsumerWidget { ); } + /// + /// SUBMIT ORDER + /// void _submitOrder(BuildContext context, WidgetRef ref, OrderType orderType) { final selectedFiatCode = ref.read(selectedFiatCodeProvider); @@ -260,12 +423,17 @@ class AddOrderScreen extends ConsumerWidget { final tempOrderId = uuid.v4(); final notifier = ref.read(addOrderNotifierProvider(tempOrderId).notifier); + final fiatAmount = int.tryParse(_fiatAmountController.text) ?? 0; + final satsAmount = int.tryParse(_satsAmountController.text) ?? 0; + final paymentMethod = _paymentMethodController.text; + notifier.submitOrder( - selectedFiatCode ?? '', // Use selected fiat code - int.tryParse(_fiatAmountController.text) ?? 0, - int.tryParse(_satsAmountController.text) ?? 0, - _paymentMethodController.text, + selectedFiatCode ?? '', + fiatAmount, + _marketRate ? 0 : satsAmount, // if market => pass 0 or ignore + paymentMethod, orderType, + lnAddress: _lightningInvoiceController.text, ); } } diff --git a/lib/features/add_order/widgets/fixed_switch_widget.dart b/lib/features/add_order/widgets/fixed_switch_widget.dart new file mode 100644 index 00000000..242d268e --- /dev/null +++ b/lib/features/add_order/widgets/fixed_switch_widget.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class FixedSwitch extends StatefulWidget { + final bool initialValue; + final ValueChanged onChanged; + + const FixedSwitch({ + super.key, + this.initialValue = false, + required this.onChanged, + }); + + @override + State createState() => _FixedSwitchState(); +} + +class _FixedSwitchState extends State { + late bool marketRate; + + @override + void initState() { + super.initState(); + marketRate = widget.initialValue; + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Switch( + value: marketRate, + onChanged: (bool value) { + setState(() { + marketRate = value; + }); + widget.onChanged(value); + }, + ), + // Label after the switch + const SizedBox(width: 8), + Text( + marketRate ? 'Market' : 'Fixed', + style: const TextStyle(color: Colors.white), + ), + ], + ); + } +} diff --git a/lib/features/home/notifiers/home_notifier.dart b/lib/features/home/notifiers/home_notifier.dart index 13ada4eb..6b5cd775 100644 --- a/lib/features/home/notifiers/home_notifier.dart +++ b/lib/features/home/notifiers/home_notifier.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'home_state.dart'; @@ -21,8 +22,7 @@ class HomeNotifier extends AsyncNotifier { _subscription = _repository!.eventsStream.listen((orders) { final orderType = state.value?.orderType ?? OrderType.sell; - final filteredOrders = - _filterOrders(orders, orderType); + final filteredOrders = _filterOrders(orders, orderType); state = AsyncData( HomeState( orderType: orderType, @@ -52,7 +52,9 @@ class HomeNotifier extends AsyncNotifier { List _filterOrders(List orders, OrderType type) { final currentState = state.value; if (currentState == null) return []; + final mostroRepository = ref.watch(mostroRepositoryProvider); return orders + .where((order) => mostroRepository.getOrderById(order.orderId!) == null) .where((order) => type == OrderType.buy ? order.orderType == OrderType.buy : order.orderType == OrderType.sell) diff --git a/lib/presentation/widgets/bottom_nav_bar.dart b/lib/presentation/widgets/bottom_nav_bar.dart index e7988011..d56bdaf1 100644 --- a/lib/presentation/widgets/bottom_nav_bar.dart +++ b/lib/presentation/widgets/bottom_nav_bar.dart @@ -21,8 +21,9 @@ class BottomNavBar extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildNavItem(context, HeroIcons.bookOpen, 0), - _buildNavItem(context, HeroIcons.chatBubbleLeftRight, 1), - _buildNavItem(context, HeroIcons.user, 2), + _buildNavItem(context, HeroIcons.bookmarkSquare, 1), + _buildNavItem(context, HeroIcons.chatBubbleLeftRight, 2), + _buildNavItem(context, HeroIcons.bolt, 3), ], ), ), @@ -51,13 +52,15 @@ class BottomNavBar extends StatelessWidget { } bool _isActive(BuildContext context, int index) { - final currentLocation = GoRouterState.of(context).uri.toString(); + final currentLocation = GoRouterState.of(context).uri.toString(); switch (index) { case 0: - return currentLocation == '/'; + return currentLocation == '/'; case 1: - return currentLocation == '/chat_list'; + return currentLocation == '/my_trades'; case 2: + return currentLocation == '/chat_list'; + case 3: return currentLocation == '/profile'; default: return false; @@ -71,16 +74,19 @@ class BottomNavBar extends StatelessWidget { nextRoute = '/'; break; case 1: - nextRoute = '/chat_list'; + nextRoute = '/my_trades'; break; case 2: + nextRoute = '/chat_list'; + break; + case 3: nextRoute = '/profile'; break; default: return; } - final currentLocation = GoRouterState.of(context).uri.toString(); + final currentLocation = GoRouterState.of(context).uri.toString(); if (currentLocation != nextRoute) { context.go(nextRoute); } diff --git a/lib/presentation/widgets/currency_dropdown.dart b/lib/presentation/widgets/currency_dropdown.dart index 6fe4347c..94442933 100644 --- a/lib/presentation/widgets/currency_dropdown.dart +++ b/lib/presentation/widgets/currency_dropdown.dart @@ -5,10 +5,12 @@ import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; class CurrencyDropdown extends ConsumerWidget { final String label; + final ValueChanged? onSelected; const CurrencyDropdown({ super.key, required this.label, + this.onSelected, }); @override @@ -32,7 +34,7 @@ class CurrencyDropdown extends ConsumerWidget { ), error: (error, stackTrace) => Row( children: [ - Text('Failed to load currencies'), + const Text('Failed to load currencies'), TextButton( onPressed: () => ref.refresh(currencyCodesProvider), child: const Text('Retry'), @@ -57,11 +59,17 @@ class CurrencyDropdown extends ConsumerWidget { labelStyle: const TextStyle(color: AppTheme.grey2), ), dropdownColor: AppTheme.dark1, - style: TextStyle(color: AppTheme.cream1), + style: const TextStyle(color: AppTheme.cream1), items: items, value: selectedFiatCode, - onChanged: (value) => - ref.read(selectedFiatCodeProvider.notifier).state = value, + onChanged: (value) { + if (value != null) { + // Update Riverpod state + ref.read(selectedFiatCodeProvider.notifier).state = value; + // Notify parent via callback if provided + onSelected?.call(value); + } + }, ); }, ), From 64efd54ce9fc20e803d246667c3312036351e356 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Tue, 21 Jan 2025 15:03:58 -0300 Subject: [PATCH 028/149] Added OrderBook feature --- lib/app/app_routes.dart | 5 + .../notifiers/order_book_notifier.dart | 15 ++ .../notifiers/order_book_state.dart | 7 + .../providers/order_book_notifier.dart | 8 + .../order_book/screens/order_book_screen.dart | 85 ++++++++ .../order_book/widgets/order_book_list.dart | 19 ++ .../widgets/order_book_list_item.dart | 198 ++++++++++++++++++ lib/presentation/widgets/bottom_nav_bar.dart | 4 +- 8 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 lib/features/order_book/notifiers/order_book_notifier.dart create mode 100644 lib/features/order_book/notifiers/order_book_state.dart create mode 100644 lib/features/order_book/providers/order_book_notifier.dart create mode 100644 lib/features/order_book/screens/order_book_screen.dart create mode 100644 lib/features/order_book/widgets/order_book_list.dart create mode 100644 lib/features/order_book/widgets/order_book_list_item.dart diff --git a/lib/app/app_routes.dart b/lib/app/app_routes.dart index 87a77ce6..df6a09b6 100644 --- a/lib/app/app_routes.dart +++ b/lib/app/app_routes.dart @@ -4,6 +4,7 @@ import 'package:mostro_mobile/features/add_order/screens/add_order_screen.dart'; import 'package:mostro_mobile/features/add_order/screens/order_confirmation_screen.dart'; import 'package:mostro_mobile/features/auth/screens/welcome_screen.dart'; import 'package:mostro_mobile/features/home/screens/home_screen.dart'; +import 'package:mostro_mobile/features/order_book/screens/order_book_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/add_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/pay_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/take_buy_order_screen.dart'; @@ -34,6 +35,10 @@ final goRouter = GoRouter( path: '/', builder: (context, state) => const HomeScreen(), ), + GoRoute( + path: '/order_book', + builder: (context, state) => const OrderBookScreen(), + ), GoRoute( path: '/chat_list', builder: (context, state) => const ChatListScreen(), diff --git a/lib/features/order_book/notifiers/order_book_notifier.dart b/lib/features/order_book/notifiers/order_book_notifier.dart new file mode 100644 index 00000000..16cff83b --- /dev/null +++ b/lib/features/order_book/notifiers/order_book_notifier.dart @@ -0,0 +1,15 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/order_book/notifiers/order_book_state.dart'; + +class OrderBookNotifier extends AsyncNotifier { + @override + FutureOr build() async { + state = const AsyncLoading(); + + return OrderBookState([]); + + } + +} \ No newline at end of file diff --git a/lib/features/order_book/notifiers/order_book_state.dart b/lib/features/order_book/notifiers/order_book_state.dart new file mode 100644 index 00000000..a6d21acb --- /dev/null +++ b/lib/features/order_book/notifiers/order_book_state.dart @@ -0,0 +1,7 @@ +import 'package:mostro_mobile/data/models/order.dart'; + +class OrderBookState { + final List orders; + + OrderBookState(this.orders); +} diff --git a/lib/features/order_book/providers/order_book_notifier.dart b/lib/features/order_book/providers/order_book_notifier.dart new file mode 100644 index 00000000..c3a7432d --- /dev/null +++ b/lib/features/order_book/providers/order_book_notifier.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/order_book/notifiers/order_book_notifier.dart'; +import 'package:mostro_mobile/features/order_book/notifiers/order_book_state.dart'; + +final orderBookNotifierProvider = AsyncNotifierProvider( + OrderBookNotifier.new, +); + diff --git a/lib/features/order_book/screens/order_book_screen.dart b/lib/features/order_book/screens/order_book_screen.dart new file mode 100644 index 00000000..f68777f2 --- /dev/null +++ b/lib/features/order_book/screens/order_book_screen.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/features/order_book/notifiers/order_book_state.dart'; +import 'package:mostro_mobile/features/order_book/providers/order_book_notifier.dart'; +import 'package:mostro_mobile/features/order_book/widgets/order_book_list.dart'; +import 'package:mostro_mobile/presentation/widgets/bottom_nav_bar.dart'; +import 'package:mostro_mobile/presentation/widgets/custom_app_bar.dart'; + +class OrderBookScreen extends ConsumerWidget { + const OrderBookScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final orderBookStateAsync = ref.watch(orderBookNotifierProvider); + + return orderBookStateAsync.when( + data: (orderBookState) { + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: const CustomAppBar(), + body: RefreshIndicator( + onRefresh: () async { + //await orderBookNotifier.refresh(); + }, + child: Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), + decoration: BoxDecoration( + color: AppTheme.dark2, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'My Order Book', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + fontFamily: GoogleFonts.robotoCondensed().fontFamily, + ), + ), + ), + Expanded( + child: _buildOrderList(orderBookState), + ), + const BottomNavBar(), + ], + ), + ), + ), + ); + }, + loading: () => const Scaffold( + backgroundColor: AppTheme.dark1, + body: Center(child: CircularProgressIndicator()), + ), + error: (error, stack) => Scaffold( + backgroundColor: AppTheme.dark1, + body: Center( + child: Text( + 'Error: $error', + style: const TextStyle(color: AppTheme.cream1), + ), + ), + ), + ); + } + + Widget _buildOrderList(OrderBookState orderBookState) { + if (orderBookState.orders.isEmpty) { + return const Center( + child: Text( + 'No orders available for this type', + style: TextStyle(color: Colors.white), + ), + ); + } + + return OrderBookList(orders: orderBookState.orders); + } +} diff --git a/lib/features/order_book/widgets/order_book_list.dart b/lib/features/order_book/widgets/order_book_list.dart new file mode 100644 index 00000000..e2967c02 --- /dev/null +++ b/lib/features/order_book/widgets/order_book_list.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/features/order_book/widgets/order_book_list_item.dart'; + +class OrderBookList extends StatelessWidget { + final List orders; + + const OrderBookList({super.key, required this.orders}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: orders.length, + itemBuilder: (context, index) { + return OrderBookListItem(order: orders[index]); + }, + ); + } +} diff --git a/lib/features/order_book/widgets/order_book_list_item.dart b/lib/features/order_book/widgets/order_book_list_item.dart new file mode 100644 index 00000000..355a501f --- /dev/null +++ b/lib/features/order_book/widgets/order_book_list_item.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/shared/widgets/custom_card.dart'; + +class OrderBookListItem extends StatelessWidget { + final Order order; + + const OrderBookListItem({super.key, required this.order}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + }, + child: CustomCard( + color: AppTheme.dark1, + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + const SizedBox(height: 16), + _buildOrderDetails(context), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${order.id}', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + ), + ), + Text( + 'Time: ', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + ), + ), + ], + ); + } + + Widget _buildOrderDetails(BuildContext context) { + return Row( + children: [ + _getOrderOffering(context, order), + const SizedBox(width: 16), + Expanded( + flex: 4, + child: _buildPaymentMethod(context), + ), + ], + ); + } + + Widget _getOrderOffering(BuildContext context, Order order) { + String offering = order.kind == OrderType.buy ? 'Buying' : 'Selling'; + String amountText = '${order.amount}'; + + return Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + children: [ + _buildStyledTextSpan( + context, + offering, + amountText, + isValue: true, + isBold: true, + ), + TextSpan( + text: 'sats', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + fontWeight: FontWeight.normal, + ), + ), + ], + ), + ), + const SizedBox(height: 8.0), + RichText( + text: TextSpan( + children: [ + _buildStyledTextSpan( + context, + 'for ', + '${order.fiatAmount}', + isValue: true, + isBold: true, + ), + TextSpan( + text: '${order.fiatCode} ', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + fontSize: 16.0, + ), + ), + TextSpan( + text: '(${order.premium}%)', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + fontSize: 16.0, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPaymentMethod(BuildContext context) { + String method = order.paymentMethod.isNotEmpty + ? order.paymentMethod + : 'No payment method'; + + return Row( + children: [ + HeroIcon( + _getPaymentMethodIcon(method), + style: HeroIconStyle.outline, + color: AppTheme.cream1, + size: 16, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + method, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.grey2, + ), + overflow: TextOverflow.ellipsis, + softWrap: true, + ), + ), + ], + ); + } + + HeroIcons _getPaymentMethodIcon(String method) { + switch (method.toLowerCase()) { + case 'wire transfer': + case 'transferencia bancaria': + return HeroIcons.buildingLibrary; + case 'revolut': + return HeroIcons.creditCard; + default: + return HeroIcons.banknotes; + } + } + + TextSpan _buildStyledTextSpan( + BuildContext context, + String label, + String value, { + bool isValue = false, + bool isBold = false, + }) { + return TextSpan( + text: label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + fontWeight: FontWeight.normal, + fontSize: isValue ? 16.0 : 24.0, + ), + children: isValue + ? [ + TextSpan( + text: '$value ', + style: Theme.of(context).textTheme.displayLarge?.copyWith( + fontWeight: isBold ? FontWeight.bold : FontWeight.normal, + fontSize: 24.0, + color: AppTheme.cream1, + ), + ), + ] + : [], + ); + } +} diff --git a/lib/presentation/widgets/bottom_nav_bar.dart b/lib/presentation/widgets/bottom_nav_bar.dart index d56bdaf1..b2457ba9 100644 --- a/lib/presentation/widgets/bottom_nav_bar.dart +++ b/lib/presentation/widgets/bottom_nav_bar.dart @@ -57,7 +57,7 @@ class BottomNavBar extends StatelessWidget { case 0: return currentLocation == '/'; case 1: - return currentLocation == '/my_trades'; + return currentLocation == '/order_book'; case 2: return currentLocation == '/chat_list'; case 3: @@ -74,7 +74,7 @@ class BottomNavBar extends StatelessWidget { nextRoute = '/'; break; case 1: - nextRoute = '/my_trades'; + nextRoute = '/order_book'; break; case 2: nextRoute = '/chat_list'; From 60703fcbb17dc54f04a73485ea2ed583162886e9 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 25 Jan 2025 11:32:32 -0800 Subject: [PATCH 029/149] Added Drawer to Home Screen --- lib/features/home/screens/home_screen.dart | 31 ++++++++++++++++++-- lib/presentation/widgets/custom_app_bar.dart | 2 +- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 64d5d0bc..3291acc4 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -24,6 +24,31 @@ class HomeScreen extends ConsumerWidget { return Scaffold( backgroundColor: AppTheme.dark1, appBar: const CustomAppBar(), + drawer: Drawer( + // Add a ListView to the drawer. This ensures the user can scroll + // through the options in the drawer if there isn't enough vertical + // space to fit everything. + child: ListView( + // Important: Remove any padding from the ListView. + padding: EdgeInsets.zero, + children: [ + ListTile( + title: const Text('Item 1'), + onTap: () { + // Update the state of the app. + // ... + }, + ), + ListTile( + title: const Text('Item 2'), + onTap: () { + // Update the state of the app. + // ... + }, + ), + ], + ), + ), body: RefreshIndicator( onRefresh: () async { await homeNotifier.refresh(); @@ -78,12 +103,14 @@ class HomeScreen extends ConsumerWidget { child: Row( children: [ Expanded( - child: _buildTab("BUY BTC", homeState.orderType == OrderType.sell, () { + child: + _buildTab("BUY BTC", homeState.orderType == OrderType.sell, () { homeNotifier.changeOrderType(OrderType.sell); }), ), Expanded( - child: _buildTab("SELL BTC", homeState.orderType == OrderType.buy, () { + child: + _buildTab("SELL BTC", homeState.orderType == OrderType.buy, () { homeNotifier.changeOrderType(OrderType.buy); }), ), diff --git a/lib/presentation/widgets/custom_app_bar.dart b/lib/presentation/widgets/custom_app_bar.dart index ea8a1fcd..0154735e 100644 --- a/lib/presentation/widgets/custom_app_bar.dart +++ b/lib/presentation/widgets/custom_app_bar.dart @@ -14,7 +14,7 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { icon: const HeroIcon(HeroIcons.bars3, style: HeroIconStyle.outline, color: Colors.white), onPressed: () { - // TODO: Implement drawer opening + Scaffold.of(context).openDrawer(); }, ), actions: [ From ff3e6d13aff033316f72e156b9d413c288a8d61b Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 25 Jan 2025 16:02:32 -0800 Subject: [PATCH 030/149] Unifed order provider fixed --- lib/app/app.dart | 2 +- lib/app/app_routes.dart | 2 +- lib/data/repositories/mostro_repository.dart | 1 + lib/data/repositories/session_manager.dart | 2 +- .../notifiers/add_order_notifier.dart | 14 +--- .../add_order/screens/add_order_screen.dart | 53 ++++++++---- .../notfiers/abstract_order_notifier.dart | 3 +- .../order/notfiers/order_notifier.dart | 2 +- .../providers/order_notifier_provider.dart | 2 + .../notifiers/take_buy_order_notifier.dart | 2 +- .../notifiers/take_sell_order_notifier.dart | 2 +- .../providers/order_notifier_providers.dart | 5 +- .../screens/take_buy_order_screen.dart | 2 +- lib/main.dart | 2 +- .../widgets/currency_text_field.dart | 82 ++++++++++++++++--- lib/presentation/widgets/custom_app_bar.dart | 5 +- lib/shared/providers/app_init_provider.dart | 13 +++ 17 files changed, 143 insertions(+), 51 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index 4d54eb4f..b9936f0c 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -24,7 +24,7 @@ class MostroApp extends ConsumerWidget { if (state is AuthAuthenticated || state is AuthRegistrationSuccess) { context.go('/'); } else if (state is AuthUnregistered || state is AuthUnauthenticated) { - context.go('/welcome'); + context.go('/'); } }); }); diff --git a/lib/app/app_routes.dart b/lib/app/app_routes.dart index df6a09b6..c9dc61fb 100644 --- a/lib/app/app_routes.dart +++ b/lib/app/app_routes.dart @@ -84,7 +84,7 @@ final goRouter = GoRouter( ], ), ], - initialLocation: '/welcome', + initialLocation: '/', errorBuilder: (context, state) => Scaffold( body: Center(child: Text(state.error.toString())), ), diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index d6602424..bb993545 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -63,6 +63,7 @@ class MostroRepository implements OrderRepository { } Future> publishOrder(MostroMessage order) async { + print(order); final session = await _mostroService.publishOrder(order); return _subscribe(session); } diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 968f8514..f7a47c23 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -44,7 +44,7 @@ class SessionManager { masterKey: masterKey, keyIndex: keyIndex, tradeKey: tradeKey, - fullPrivacy: false, + fullPrivacy: true, orderId: orderId, ); _sessions[keyIndex] = session; diff --git a/lib/features/add_order/notifiers/add_order_notifier.dart b/lib/features/add_order/notifiers/add_order_notifier.dart index 0b7c8ff2..4f72889d 100644 --- a/lib/features/add_order/notifiers/add_order_notifier.dart +++ b/lib/features/add_order/notifiers/add_order_notifier.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.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/order.dart'; import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; @@ -11,18 +10,9 @@ class AddOrderNotifier extends AbstractOrderNotifier { final String uuid; AddOrderNotifier(MostroRepository orderRepository, this.uuid, Ref ref) - : super(orderRepository, uuid, ref); + : super(orderRepository, uuid, ref, Action.newOrder); - Future submitOrder(String fiatCode, int fiatAmount, int satsAmount, - String paymentMethod, OrderType orderType, - {String? lnAddress}) async { - final order = Order( - fiatAmount: fiatAmount, - fiatCode: fiatCode, - kind: orderType, - paymentMethod: paymentMethod, - buyerInvoice: lnAddress, - ); + Future submitOrder(Order order) async { final message = MostroMessage(action: Action.newOrder, id: null, payload: order); final stream = await orderRepository.publishOrder(message); diff --git a/lib/features/add_order/screens/add_order_screen.dart b/lib/features/add_order/screens/add_order_screen.dart index 19e679ad..8adc21f1 100644 --- a/lib/features/add_order/screens/add_order_screen.dart +++ b/lib/features/add_order/screens/add_order_screen.dart @@ -6,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/app/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/add_order/providers/add_order_notifier_provider.dart'; import 'package:mostro_mobile/features/add_order/widgets/fixed_switch_widget.dart'; import 'package:mostro_mobile/presentation/widgets/currency_dropdown.dart'; @@ -22,6 +23,8 @@ class AddOrderScreen extends ConsumerStatefulWidget { class _AddOrderScreenState extends ConsumerState { final _formKey = GlobalKey(); + final GlobalKey _rangeKey = + GlobalKey(); final _fiatAmountController = TextEditingController(); final _satsAmountController = TextEditingController(); @@ -169,6 +172,7 @@ class _AddOrderScreenState extends ConsumerState { _buildDisabledWrapper( enabled: _isEnabled, child: CurrencyTextField( + key: _rangeKey, controller: _fiatAmountController, label: 'Fiat amount', ), @@ -248,6 +252,7 @@ class _AddOrderScreenState extends ConsumerState { _buildDisabledWrapper( enabled: _isEnabled, child: CurrencyTextField( + key: _rangeKey, controller: _fiatAmountController, label: 'Fiat amount', ), @@ -287,17 +292,16 @@ class _AddOrderScreenState extends ConsumerState { suffix: Icon(BitcoinIcons.satoshi_v1_outline).icon), ), const SizedBox(height: 16), - _buildDisabledWrapper( - enabled: _isEnabled, - child: _buildTextField( - 'Lightning Invoice without an amount', - _lightningInvoiceController, - ), - ), ], ), + _buildDisabledWrapper( + enabled: _isEnabled, + child: _buildTextField( + 'Lightning Invoice or Lightning Address', + _lightningInvoiceController, + ), + ), const SizedBox(height: 16), - // 6) Payment method _buildDisabledWrapper( enabled: _isEnabled, @@ -423,18 +427,35 @@ class _AddOrderScreenState extends ConsumerState { final tempOrderId = uuid.v4(); final notifier = ref.read(addOrderNotifierProvider(tempOrderId).notifier); - final fiatAmount = int.tryParse(_fiatAmountController.text) ?? 0; + final currencyFieldState = _rangeKey.currentState; + final fiatAmount = currencyFieldState?.maxAmount != null + ? 0 + : currencyFieldState?.minAmount; + final minAmount = currencyFieldState?.maxAmount != null + ? currencyFieldState?.minAmount + : null; + final maxAmount = currencyFieldState?.maxAmount; + final satsAmount = int.tryParse(_satsAmountController.text) ?? 0; final paymentMethod = _paymentMethodController.text; - notifier.submitOrder( - selectedFiatCode ?? '', - fiatAmount, - _marketRate ? 0 : satsAmount, // if market => pass 0 or ignore - paymentMethod, - orderType, - lnAddress: _lightningInvoiceController.text, + final buyerInvoice = _lightningInvoiceController.text.isEmpty + ? null + : _lightningInvoiceController.text; + + final order = Order( + kind: orderType, + fiatCode: selectedFiatCode!, + fiatAmount: fiatAmount!, + minAmount: minAmount, + maxAmount: maxAmount, + paymentMethod: paymentMethod, + amount: _marketRate ? 0 : satsAmount, + premium: _marketRate ? _premiumValue.toInt() : 0, + buyerInvoice: buyerInvoice, ); + + notifier.submitOrder(order); } } } diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index e15d0e07..244cb1ad 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -17,8 +17,9 @@ class AbstractOrderNotifier extends StateNotifier { this.orderRepository, this.orderId, this.ref, + Action action, ) : super(orderRepository.getOrderById(orderId) ?? - MostroMessage(action: Action.notFound, id: orderId)); + MostroMessage(action: action, id: orderId)); Future subscribe(Stream stream) async { try { diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 06d0f4bf..3f552ad1 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; class OrderNotifier extends AbstractOrderNotifier { - OrderNotifier(super.orderRepository, super.orderId, super.ref) { + OrderNotifier(super.orderRepository, super.orderId, super.ref, super.action) { _reSubscribe(); } diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index 534c9d6d..3729cd5f 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; @@ -11,6 +12,7 @@ final orderNotifierProvider = repo, orderId, ref, + Action.newOrder ); }, ); diff --git a/lib/features/take_order/notifiers/take_buy_order_notifier.dart b/lib/features/take_order/notifiers/take_buy_order_notifier.dart index 5b47de99..f3635aed 100644 --- a/lib/features/take_order/notifiers/take_buy_order_notifier.dart +++ b/lib/features/take_order/notifiers/take_buy_order_notifier.dart @@ -2,7 +2,7 @@ import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.da class TakeBuyOrderNotifier extends AbstractOrderNotifier { - TakeBuyOrderNotifier(super.orderRepository, super.orderId, super.ref); + TakeBuyOrderNotifier(super.orderRepository, super.orderId, super.ref, super.action); void takeBuyOrder(String orderId, int? amount) async { final stream = await orderRepository.takeBuyOrder(orderId, amount); diff --git a/lib/features/take_order/notifiers/take_sell_order_notifier.dart b/lib/features/take_order/notifiers/take_sell_order_notifier.dart index 6d02badd..f7aa3839 100644 --- a/lib/features/take_order/notifiers/take_sell_order_notifier.dart +++ b/lib/features/take_order/notifiers/take_sell_order_notifier.dart @@ -1,7 +1,7 @@ import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; class TakeSellOrderNotifier extends AbstractOrderNotifier { - TakeSellOrderNotifier(super.orderRepository, super.orderId, super.ref); + TakeSellOrderNotifier(super.orderRepository, super.orderId, super.ref, super.action); void takeSellOrder(String orderId, int? amount, String? lnAddress) async { final stream = diff --git a/lib/features/take_order/providers/order_notifier_providers.dart b/lib/features/take_order/providers/order_notifier_providers.dart index 3d807263..2763fb75 100644 --- a/lib/features/take_order/providers/order_notifier_providers.dart +++ b/lib/features/take_order/providers/order_notifier_providers.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/features/take_order/notifiers/take_buy_order_notifier.dart'; import 'package:mostro_mobile/features/take_order/notifiers/take_sell_order_notifier.dart'; @@ -8,12 +9,12 @@ final takeSellOrderNotifierProvider = StateNotifierProvider.family( (ref, orderId) { final repository = ref.watch(mostroRepositoryProvider); - return TakeSellOrderNotifier(repository, orderId, ref); + return TakeSellOrderNotifier(repository, orderId, ref, Action.takeSell); }); final takeBuyOrderNotifierProvider = StateNotifierProvider.family( (ref, orderId) { final repository = ref.watch(mostroRepositoryProvider); - return TakeBuyOrderNotifier(repository, orderId, ref); + return TakeBuyOrderNotifier(repository, orderId, ref, Action.takeBuy); }); diff --git a/lib/features/take_order/screens/take_buy_order_screen.dart b/lib/features/take_order/screens/take_buy_order_screen.dart index bcf8b268..f41ea49e 100644 --- a/lib/features/take_order/screens/take_buy_order_screen.dart +++ b/lib/features/take_order/screens/take_buy_order_screen.dart @@ -23,7 +23,7 @@ class TakeBuyOrderScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final initialOrder = ref.read(eventProvider(orderId)); + final initialOrder = ref.watch(eventProvider(orderId)); return Scaffold( backgroundColor: AppTheme.dark1, diff --git a/lib/main.dart b/lib/main.dart index 72542c50..3fce96d1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,7 +17,7 @@ void main() async { final biometricsHelper = BiometricsHelper(); final sharedPreferences = SharedPreferencesAsync(); final secureStorage = const FlutterSecureStorage(); - + runApp( ProviderScope( overrides: [ diff --git a/lib/presentation/widgets/currency_text_field.dart b/lib/presentation/widgets/currency_text_field.dart index ca8ca692..964405ae 100644 --- a/lib/presentation/widgets/currency_text_field.dart +++ b/lib/presentation/widgets/currency_text_field.dart @@ -1,14 +1,68 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/services/currency_input_formatter.dart'; -class CurrencyTextField extends StatelessWidget { +class CurrencyTextField extends StatefulWidget { final TextEditingController controller; final String label; - const CurrencyTextField( - {super.key, required this.controller, required this.label}); + /// If a single integer is entered, do we treat min and max as the same number (true) + /// or do we treat max as null (false)? + final bool singleValueSetsMaxSameAsMin; + + const CurrencyTextField({ + super.key, + required this.controller, + required this.label, + this.singleValueSetsMaxSameAsMin = false, + }); + + @override + State createState() => CurrencyTextFieldState(); +} + +class CurrencyTextFieldState extends State { + /// This method does a final parse of the user input. + /// Returns (minVal, maxVal) as int? (they can be null if invalid). + (int?, int?) _parseInput() { + final text = widget.controller.text.trim(); + if (text.isEmpty) return (null, null); + + // If there's a dash, we expect two integers + if (text.contains('-')) { + final parts = text.split('-'); + if (parts.length == 2) { + final minStr = parts[0].trim(); + final maxStr = parts[1].trim(); + + // both must be non-empty and parse to int + if (minStr.isEmpty || maxStr.isEmpty) { + return (null, null); + } + final minVal = int.tryParse(minStr); + final maxVal = int.tryParse(maxStr); + return (minVal, maxVal); + } else { + // e.g. "10-20-30" => invalid + return (null, null); + } + } else { + // Single integer + final singleVal = int.tryParse(text); + if (singleVal != null) { + if (widget.singleValueSetsMaxSameAsMin) { + return (singleVal, singleVal); + } else { + return (singleVal, null); + } + } + return (null, null); + } + } + + /// Public getters + int? get minAmount => _parseInput().$1; + int? get maxAmount => _parseInput().$2; @override Widget build(BuildContext context) { @@ -19,22 +73,30 @@ class CurrencyTextField extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: TextFormField( - controller: controller, - keyboardType: TextInputType.numberWithOptions(decimal: true), + controller: widget.controller, + keyboardType: TextInputType.number, + // The input formatter allows partial states like "10-" or just "-" inputFormatters: [ - CurrencyInputFormatter(), - FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + FilteringTextInputFormatter.allow(RegExp(r'^[0-9]*-?[0-9]*$')), ], style: const TextStyle(color: Colors.white), decoration: InputDecoration( border: InputBorder.none, - labelText: label, + labelText: widget.label, labelStyle: const TextStyle(color: Colors.grey), ), validator: (value) { - if (value == null || value.isEmpty) { + if (value == null || value.trim().isEmpty) { return 'Please enter a value'; } + final (minVal, maxVal) = _parseInput(); + if (minVal == null && maxVal == null) { + return 'Invalid number or range'; + } + // If we want to ensure min <= max, we can do: + if (minVal != null && maxVal != null && minVal > maxVal) { + return 'Minimum cannot exceed maximum'; + } return null; }, ), diff --git a/lib/presentation/widgets/custom_app_bar.dart b/lib/presentation/widgets/custom_app_bar.dart index 0154735e..112cc229 100644 --- a/lib/presentation/widgets/custom_app_bar.dart +++ b/lib/presentation/widgets/custom_app_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { const CustomAppBar({super.key}); @@ -29,8 +30,8 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { IconButton( icon: const HeroIcon(HeroIcons.bolt, style: HeroIconStyle.solid, color: Color(0xFF8CC541)), - onPressed: () { - // TODO: Implement profile action + onPressed: () async { + await clearAppData(); }, ), ], diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 15ba798d..4d64ea77 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -1,8 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; final appInitializerProvider = FutureProvider((ref) async { final keyManager = ref.read(keyManagerProvider); @@ -20,3 +22,14 @@ final appInitializerProvider = FutureProvider((ref) async { ref.read(orderNotifierProvider(orderId).notifier); } }); + + +Future clearAppData() async { + // 1) SharedPreferences + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + + // 2) Flutter Secure Storage + const secureStorage = FlutterSecureStorage(); + await secureStorage.deleteAll(); +} \ No newline at end of file From 37494918e5251aa6b9f8b4712f4b0eec50ceff68 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 25 Jan 2025 21:33:14 -0800 Subject: [PATCH 031/149] added Chat and Database support --- android/app/proguard-rules.pro | 3 +- lib/app/app_routes.dart | 2 +- lib/app/config.dart | 5 +- lib/app/foreground_service_controller.dart | 21 ++ lib/data/models/order.dart | 49 ++++ lib/data/repositories/mostro_repository.dart | 40 ++- .../repositories/open_orders_repository.dart | 36 ++- .../order_repository_encrypted.dart | 131 ++++++++++ .../order_repository_interface.dart | 22 +- .../chat/notifiers/chat_detail_notifier.dart | 38 +++ .../chat/notifiers/chat_detail_state.dart | 31 +++ .../chat/notifiers/chat_list_notifier.dart | 36 +++ .../chat/notifiers}/chat_list_state.dart | 17 +- .../chat/providers/chat_list_provider.dart | 15 ++ .../chat/screens/chat_detail_screen.dart | 125 +++++++++ .../chat/screens/chat_list_screen.dart | 152 +++++++++++ .../home/notifiers/home_notifier.dart | 1 - .../notfiers/abstract_order_notifier.dart | 3 +- .../screens/add_lightning_invoice_screen.dart | 244 +++++++++++------- .../screens/pay_lightning_invoice_screen.dart | 92 +++++-- .../screens/take_buy_order_screen.dart | 171 ++++++------ .../screens/take_sell_order_screen.dart | 184 ++++++------- .../chat_detail/bloc/chat_detail_bloc.dart | 18 -- .../chat_detail/bloc/chat_detail_event.dart | 26 -- .../chat_detail/bloc/chat_detail_state.dart | 44 ---- .../screens/chat_detail_screen.dart | 120 --------- .../chat_list/bloc/chat_list_bloc.dart | 42 --- .../chat_list/bloc/chat_list_event.dart | 10 - .../chat_list/screens/chat_list_screen.dart | 141 ---------- lib/services/nostr_service.dart | 18 +- .../providers/order_repository_provider.dart | 2 +- macos/Flutter/GeneratedPluginRegistrant.swift | 6 +- pubspec.lock | 162 +++++++----- pubspec.yaml | 11 +- 34 files changed, 1212 insertions(+), 806 deletions(-) create mode 100644 lib/app/foreground_service_controller.dart create mode 100644 lib/data/repositories/order_repository_encrypted.dart create mode 100644 lib/features/chat/notifiers/chat_detail_notifier.dart create mode 100644 lib/features/chat/notifiers/chat_detail_state.dart create mode 100644 lib/features/chat/notifiers/chat_list_notifier.dart rename lib/{presentation/chat_list/bloc => features/chat/notifiers}/chat_list_state.dart (51%) create mode 100644 lib/features/chat/providers/chat_list_provider.dart create mode 100644 lib/features/chat/screens/chat_detail_screen.dart create mode 100644 lib/features/chat/screens/chat_list_screen.dart delete mode 100644 lib/presentation/chat_detail/bloc/chat_detail_bloc.dart delete mode 100644 lib/presentation/chat_detail/bloc/chat_detail_event.dart delete mode 100644 lib/presentation/chat_detail/bloc/chat_detail_state.dart delete mode 100644 lib/presentation/chat_detail/screens/chat_detail_screen.dart delete mode 100644 lib/presentation/chat_list/bloc/chat_list_bloc.dart delete mode 100644 lib/presentation/chat_list/bloc/chat_list_event.dart delete mode 100644 lib/presentation/chat_list/screens/chat_list_screen.dart diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 731cf820..6dc7a906 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -4,4 +4,5 @@ -dontwarn com.google.errorprone.annotations.Immutable -dontwarn com.google.errorprone.annotations.RestrictedApi -dontwarn javax.annotation.Nullable --dontwarn javax.annotation.concurrent.GuardedBy \ No newline at end of file +-dontwarn javax.annotation.concurrent.GuardedBy +-keep class net.sqlcipher.** { *; } \ No newline at end of file diff --git a/lib/app/app_routes.dart b/lib/app/app_routes.dart index c9dc61fb..02a34cb5 100644 --- a/lib/app/app_routes.dart +++ b/lib/app/app_routes.dart @@ -3,13 +3,13 @@ import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/features/add_order/screens/add_order_screen.dart'; import 'package:mostro_mobile/features/add_order/screens/order_confirmation_screen.dart'; import 'package:mostro_mobile/features/auth/screens/welcome_screen.dart'; +import 'package:mostro_mobile/features/chat/screens/chat_list_screen.dart'; import 'package:mostro_mobile/features/home/screens/home_screen.dart'; import 'package:mostro_mobile/features/order_book/screens/order_book_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/add_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/pay_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/take_buy_order_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/take_sell_order_screen.dart'; -import 'package:mostro_mobile/presentation/chat_list/screens/chat_list_screen.dart'; import 'package:mostro_mobile/presentation/profile/screens/profile_screen.dart'; import 'package:mostro_mobile/features/auth/screens/register_screen.dart'; import 'package:mostro_mobile/shared/widgets/navigation_listener_widget.dart'; diff --git a/lib/app/config.dart b/lib/app/config.dart index 8f9930f8..6da66bea 100644 --- a/lib/app/config.dart +++ b/lib/app/config.dart @@ -3,9 +3,10 @@ import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ - //'ws://127.0.0.1:7000', // localhost + //'ws://127.0.0.1:7000', //'ws://10.0.2.2:7000', // mobile emulator - 'wss://relay.mostro.network', + 'ws://192.168.1.148:7000', + //'wss://relay.mostro.network', ]; // hexkey de Mostro diff --git a/lib/app/foreground_service_controller.dart b/lib/app/foreground_service_controller.dart new file mode 100644 index 00000000..0b4493e3 --- /dev/null +++ b/lib/app/foreground_service_controller.dart @@ -0,0 +1,21 @@ +import 'package:flutter/services.dart'; + +class ForegroundServiceController { + static const _channel = MethodChannel('com.example.myapp/foreground_service'); + + static Future startService() async { + try { + await _channel.invokeMethod('startService'); + } on PlatformException catch (e) { + print("Failed to start service: ${e.message}"); + } + } + + static Future stopService() async { + try { + await _channel.invokeMethod('stopService'); + } on PlatformException catch (e) { + print("Failed to stop service: ${e.message}"); + } + } +} diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart index 8a8e2a5f..56ed9c55 100644 --- a/lib/data/models/order.dart +++ b/lib/data/models/order.dart @@ -128,6 +128,55 @@ class Order implements Payload { ); } + Map toMap() { + return { + 'id': id, + 'kind': kind.value, // from OrderType + 'status': status.value, // from Status + 'amount': amount, + 'fiatCode': fiatCode, + 'minAmount': minAmount, + 'maxAmount': maxAmount, + 'fiatAmount': fiatAmount, + 'paymentMethod': paymentMethod, + 'premium': premium, + 'masterBuyerPubkey': masterBuyerPubkey, + 'masterSellerPubkey': masterSellerPubkey, + 'buyerInvoice': buyerInvoice, + 'createdAt': createdAt, + 'expiresAt': expiresAt, + 'buyerToken': buyerToken, + 'sellerToken': sellerToken, + }; + } + + // Construct from a Map (row in DB) + factory Order.fromMap(Map map) { + return Order( + id: map['id'] as String?, + kind: OrderType.fromString(map['kind'] as String), + status: Status.fromString(map['status'] as String), + amount: map['amount'] as int, + fiatCode: map['fiatCode'] as String, + minAmount: map['minAmount'] as int?, + maxAmount: map['maxAmount'] as int?, + fiatAmount: map['fiatAmount'] as int, + paymentMethod: map['paymentMethod'] as String, + premium: map['premium'] as int, + masterBuyerPubkey: map['masterBuyerPubkey'] as String?, + masterSellerPubkey: map['masterSellerPubkey'] as String?, + buyerInvoice: map['buyerInvoice'] as String?, + createdAt: map['createdAt'] as int?, + expiresAt: map['expiresAt'] as int?, + buyerToken: map['buyerToken'] as int?, + sellerToken: map['sellerToken'] as int?, + ); + } + @override String get type => 'order'; + + copyWith({required String buyerInvoice}) { + + } } diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index bb993545..125a6e5b 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -1,14 +1,16 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/payload.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; -class MostroRepository implements OrderRepository { +class MostroRepository implements OrderRepository { final MostroService _mostroService; final FlutterSecureStorage _secureStorage; final Map _messages = {}; @@ -17,7 +19,11 @@ class MostroRepository implements OrderRepository { MostroRepository(this._mostroService, this._secureStorage); - MostroMessage? getOrderById(String orderId) => _messages[orderId]; + final _logger = Logger(); + + @override + Future getOrderById(String orderId) => Future.value(_messages[orderId]); + List get allMessages => _messages.values.toList(); Stream _subscribe(Session session) { @@ -32,7 +38,7 @@ class MostroRepository implements OrderRepository { }, onError: (error) { // Log or handle subscription errors - print('Error in subscription for session ${session.keyIndex}: $error'); + _logger.e('Error in subscription for session ${session.keyIndex}: $error'); }, cancelOnError: false, ); @@ -63,7 +69,7 @@ class MostroRepository implements OrderRepository { } Future> publishOrder(MostroMessage order) async { - print(order); + _logger.i(order); final session = await _mostroService.publishOrder(order); return _subscribe(session); } @@ -99,7 +105,7 @@ class MostroRepository implements OrderRepository { final msg = MostroMessage.deserialized(entry.value); _messages[msg.id!] = msg; } catch (e) { - print('Error deserializing message for key ${entry.key}: $e'); + _logger.e('Error deserializing message for key ${entry.key}: $e'); } } } @@ -112,4 +118,28 @@ class MostroRepository implements OrderRepository { } _subscriptions.clear(); } + + @override + Future addOrder(MostroMessage order) { + // TODO: implement addOrder + throw UnimplementedError(); + } + + @override + Future deleteOrder(String orderId) { + // TODO: implement deleteOrder + throw UnimplementedError(); + } + + @override + Future>> getAllOrders() { + // TODO: implement getAllOrders + throw UnimplementedError(); + } + + @override + Future updateOrder(MostroMessage order) { + // TODO: implement updateOrder + throw UnimplementedError(); + } } diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index a20dede5..ee175ed2 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:dart_nostr/nostr/model/request/filter.dart'; +import 'package:logger/logger.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'; @@ -8,11 +9,12 @@ import 'package:mostro_mobile/services/nostr_service.dart'; const orderEventKind = 38383; const orderFilterDurationHours = 48; -class OpenOrdersRepository implements OrderRepository { +class OpenOrdersRepository implements OrderRepository { final NostrService _nostrService; final StreamController> _eventStreamController = StreamController.broadcast(); final Map _events = {}; + final _logger = Logger(); StreamSubscription? _subscription; OpenOrdersRepository(this._nostrService); @@ -32,8 +34,7 @@ class OpenOrdersRepository implements OrderRepository { _events[event.orderId!] = event; _eventStreamController.add(_events.values.toList()); }, onError: (error) { - // Log error and optionally notify listeners - print('Error in order subscription: $error'); + _logger.e('Error in order subscription: $error'); }); } @@ -44,11 +45,36 @@ class OpenOrdersRepository implements OrderRepository { _events.clear(); } - NostrEvent? getOrderById(String orderId) { - return _events[orderId]; + @override + Future getOrderById(String orderId) { + return Future.value(_events[orderId]); } Stream> get eventsStream => _eventStreamController.stream; List get currentEvents => _events.values.toList(); + + @override + Future addOrder(NostrEvent order) { + // TODO: implement addOrder + throw UnimplementedError(); + } + + @override + Future deleteOrder(String orderId) { + // TODO: implement deleteOrder + throw UnimplementedError(); + } + + @override + Future> getAllOrders() { + // TODO: implement getAllOrders + throw UnimplementedError(); + } + + @override + Future updateOrder(NostrEvent order) { + // TODO: implement updateOrder + throw UnimplementedError(); + } } diff --git a/lib/data/repositories/order_repository_encrypted.dart b/lib/data/repositories/order_repository_encrypted.dart new file mode 100644 index 00000000..86d4d883 --- /dev/null +++ b/lib/data/repositories/order_repository_encrypted.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; +import 'package:path/path.dart'; +import 'package:sqflite_sqlcipher/sqflite.dart'; +import 'package:mostro_mobile/data/models/order.dart'; + +class OrderRepositoryEncrypted implements OrderRepository { + static const _dbName = 'orders_encrypted.db'; + static const _dbVersion = 1; + static const _tableName = 'orders'; + + final String _dbPassword; + + Database? _database; + + OrderRepositoryEncrypted({required String dbPassword}) + : _dbPassword = dbPassword; + + /// Return the single instance of Database, opening if needed + Future get database async { + if (_database != null) return _database!; + _database = await _initDatabase(); + return _database!; + } + + /// Initialize the encrypted database + Future _initDatabase() async { + final docDir = await getDatabasesPath(); + final dbPath = join(docDir, _dbName); + + return await openDatabase( + dbPath, + password: _dbPassword, + version: _dbVersion, + onCreate: _onCreate, + // onUpgrade: _onUpgrade if needed + ); + } + + Future _onCreate(Database db, int version) async { + // Create table with all columns from the Order model + // id is primary key, so if you don't expect collisions, use that + await db.execute(''' + CREATE TABLE $_tableName ( + id TEXT PRIMARY KEY, + kind TEXT, + status TEXT, + amount INTEGER, + fiatCode TEXT, + minAmount INTEGER, + maxAmount INTEGER, + fiatAmount INTEGER, + paymentMethod TEXT, + premium INTEGER, + masterBuyerPubkey TEXT, + masterSellerPubkey TEXT, + buyerInvoice TEXT, + createdAt INTEGER, + expiresAt INTEGER, + buyerToken INTEGER, + sellerToken INTEGER + ) + '''); + } + + // region: CRUD + + @override + Future addOrder(Order order) async { + final db = await database; + await db.insert( + _tableName, + order.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + @override + Future> getAllOrders() async { + final db = await database; + final results = await db.query(_tableName); + return results.map((map) => Order.fromMap(map)).toList(); + } + + @override + Future getOrderById(String orderId) async { + final db = await database; + final results = await db.query( + _tableName, + where: 'id = ?', + whereArgs: [orderId], + ); + if (results.isNotEmpty) { + return Order.fromMap(results.first); + } + return null; + } + + @override + Future updateOrder(Order order) async { + // The order must have a valid id + if (order.id == null) { + throw ArgumentError('Cannot update an Order with null ID'); + } + + final db = await database; + await db.update( + _tableName, + order.toMap(), + where: 'id = ?', + whereArgs: [order.id], + ); + } + + @override + Future deleteOrder(String orderId) async { + final db = await database; + await db.delete( + _tableName, + where: 'id = ?', + whereArgs: [orderId], + ); + } + + // endregion + + @override + void dispose() { + // TODO: implement dispose + } +} diff --git a/lib/data/repositories/order_repository_interface.dart b/lib/data/repositories/order_repository_interface.dart index b5ca6c8b..e7893ab2 100644 --- a/lib/data/repositories/order_repository_interface.dart +++ b/lib/data/repositories/order_repository_interface.dart @@ -1,18 +1,8 @@ -abstract class OrderRepository { +abstract class OrderRepository { void dispose(); -} - -enum MessageKind { - openOrder(8383), - directMessage(1059); - - final int kind; - const MessageKind(this.kind); -} - -class OrderFilter { - final List messageKinds; - - OrderFilter({required this.messageKinds}); - + Future addOrder(T order); + Future> getAllOrders(); + Future getOrderById(String orderId); + Future updateOrder(T order); + Future deleteOrder(String orderId); } diff --git a/lib/features/chat/notifiers/chat_detail_notifier.dart b/lib/features/chat/notifiers/chat_detail_notifier.dart new file mode 100644 index 00000000..855721db --- /dev/null +++ b/lib/features/chat/notifiers/chat_detail_notifier.dart @@ -0,0 +1,38 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'chat_detail_state.dart'; + +class ChatDetailNotifier extends StateNotifier { + final String chatId; + + ChatDetailNotifier(this.chatId) : super(const ChatDetailState()) { + loadChatDetail(); + } + + Future loadChatDetail() async { + try { + state = state.copyWith(status: ChatDetailStatus.loading); + + // Simulate a delay / fetch from repo + await Future.delayed(const Duration(seconds: 1)); + // Example data + final chatMessages = []; + + state = state.copyWith( + status: ChatDetailStatus.loaded, + messages: chatMessages, + error: null, + ); + } catch (e) { + state = state.copyWith( + status: ChatDetailStatus.error, + error: e.toString(), + ); + } + } + + void sendMessage(String text) { + final updated = List.from(state.messages); + state = state.copyWith(messages: updated); + } +} diff --git a/lib/features/chat/notifiers/chat_detail_state.dart b/lib/features/chat/notifiers/chat_detail_state.dart new file mode 100644 index 00000000..83a0e761 --- /dev/null +++ b/lib/features/chat/notifiers/chat_detail_state.dart @@ -0,0 +1,31 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; + +enum ChatDetailStatus { + loading, + loaded, + error, +} + +class ChatDetailState { + final ChatDetailStatus status; + final List messages; + final String? error; + + const ChatDetailState({ + this.status = ChatDetailStatus.loading, + this.messages = const [], + this.error, + }); + + ChatDetailState copyWith({ + ChatDetailStatus? status, + List? messages, + String? error, + }) { + return ChatDetailState( + status: status ?? this.status, + messages: messages ?? this.messages, + error: error, + ); + } +} diff --git a/lib/features/chat/notifiers/chat_list_notifier.dart b/lib/features/chat/notifiers/chat_list_notifier.dart new file mode 100644 index 00000000..f0c7130a --- /dev/null +++ b/lib/features/chat/notifiers/chat_list_notifier.dart @@ -0,0 +1,36 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'chat_list_state.dart'; + +class ChatListNotifier extends StateNotifier { + ChatListNotifier() : super(const ChatListState()) { + loadChats(); + } + + Future loadChats() async { + try { + // 1) Start loading + state = state.copyWith(status: ChatListStatus.loading); + + // 2) Simulate or fetch real chat data from a repository + // For example: + // final chats = await chatRepository.getAllChats(); + // For now, a mock: + await Future.delayed(const Duration(seconds: 1)); // simulating network + final chats = []; + + // 3) Loaded + state = state.copyWith( + status: ChatListStatus.loaded, + chats: chats, + errorMessage: null, + ); + } catch (e) { + // On error + state = state.copyWith( + status: ChatListStatus.error, + errorMessage: e.toString(), + ); + } + } +} diff --git a/lib/presentation/chat_list/bloc/chat_list_state.dart b/lib/features/chat/notifiers/chat_list_state.dart similarity index 51% rename from lib/presentation/chat_list/bloc/chat_list_state.dart rename to lib/features/chat/notifiers/chat_list_state.dart index 6417ada0..0467bd4a 100644 --- a/lib/presentation/chat_list/bloc/chat_list_state.dart +++ b/lib/features/chat/notifiers/chat_list_state.dart @@ -1,22 +1,22 @@ -import 'package:equatable/equatable.dart'; -import 'package:mostro_mobile/data/models/chat_model.dart'; +import 'package:dart_nostr/nostr/model/event/event.dart'; -enum ChatListStatus { initial, loading, loaded, error } +enum ChatListStatus { loading, loaded, error, empty } -class ChatListState extends Equatable { +class ChatListState { final ChatListStatus status; - final List chats; + final List chats; final String? errorMessage; const ChatListState({ - this.status = ChatListStatus.initial, + this.status = ChatListStatus.loading, this.chats = const [], this.errorMessage, }); + // A copyWith for convenience ChatListState copyWith({ ChatListStatus? status, - List? chats, + List? chats, String? errorMessage, }) { return ChatListState( @@ -25,7 +25,4 @@ class ChatListState extends Equatable { errorMessage: errorMessage ?? this.errorMessage, ); } - - @override - List get props => [status, chats, errorMessage]; } diff --git a/lib/features/chat/providers/chat_list_provider.dart b/lib/features/chat/providers/chat_list_provider.dart new file mode 100644 index 00000000..809a2cae --- /dev/null +++ b/lib/features/chat/providers/chat_list_provider.dart @@ -0,0 +1,15 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_detail_notifier.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_detail_state.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_list_notifier.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_list_state.dart'; + +final chatListNotifierProvider = + StateNotifierProvider( + (ref) => ChatListNotifier(), +); + +final chatDetailNotifierProvider = StateNotifierProvider.family< + ChatDetailNotifier, ChatDetailState, String>((ref, chatId) { + return ChatDetailNotifier(chatId); +}); \ No newline at end of file diff --git a/lib/features/chat/screens/chat_detail_screen.dart b/lib/features/chat/screens/chat_detail_screen.dart new file mode 100644 index 00000000..56fcf37e --- /dev/null +++ b/lib/features/chat/screens/chat_detail_screen.dart @@ -0,0 +1,125 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_detail_state.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_list_provider.dart'; +import 'package:mostro_mobile/presentation/widgets/bottom_nav_bar.dart'; + +class ChatDetailScreen extends ConsumerStatefulWidget { + final String chatId; + + const ChatDetailScreen({super.key, required this.chatId}); + + @override + ConsumerState createState() => _ChatDetailScreenState(); +} + +class _ChatDetailScreenState extends ConsumerState { + final TextEditingController _textController = TextEditingController(); + + @override + Widget build(BuildContext context) { + final chatDetailState = ref.watch(chatDetailNotifierProvider(widget.chatId)); + + return Scaffold( + backgroundColor: const Color(0xFF1D212C), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: const Text('JACK FOOTSEY'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/'), + ), + ), + body: _buildBody(chatDetailState), + bottomNavigationBar: const BottomNavBar(), + ); + } + + Widget _buildBody(ChatDetailState state) { + switch (state.status) { + case ChatDetailStatus.loading: + return const Center(child: CircularProgressIndicator()); + case ChatDetailStatus.loaded: + return Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: state.messages.length, + itemBuilder: (context, index) { + final message = state.messages[index]; + return _buildMessageBubble(message); + }, + ), + ), + _buildMessageInput(), + ], + ); + case ChatDetailStatus.error: + return Center(child: Text(state.error ?? 'An error occurred')); + } + } + + Widget _buildMessageBubble(NostrEvent message) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + alignment: message.pubkey == 'Mostro' + ? Alignment.centerLeft + : Alignment.centerRight, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: message.pubkey == 'Mostro' + ? const Color(0xFF303544) + : const Color(0xFF8CC541), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + message.content!, + style: const TextStyle(color: Colors.white), + ), + ), + ); + } + + Widget _buildMessageInput() { + return Container( + padding: const EdgeInsets.all(8), + color: const Color(0xFF303544), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Type a message...', + hintStyle: const TextStyle(color: Colors.grey), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: const Color(0xFF1D212C), + ), + ), + ), + IconButton( + icon: const Icon(Icons.send, color: Color(0xFF8CC541)), + onPressed: () { + final text = _textController.text.trim(); + if (text.isNotEmpty) { + ref + .read(chatDetailNotifierProvider(widget.chatId).notifier) + .sendMessage(text); + _textController.clear(); + } + }, + ), + ], + ), + ); + } +} diff --git a/lib/features/chat/screens/chat_list_screen.dart b/lib/features/chat/screens/chat_list_screen.dart new file mode 100644 index 00000000..39c5a7d5 --- /dev/null +++ b/lib/features/chat/screens/chat_list_screen.dart @@ -0,0 +1,152 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:mostro_mobile/data/models/chat_model.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_list_state.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_list_provider.dart'; +import 'package:mostro_mobile/presentation/widgets/bottom_nav_bar.dart'; +import 'package:mostro_mobile/presentation/widgets/custom_app_bar.dart'; + +class ChatListScreen extends ConsumerWidget { + const ChatListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Watch the state + final chatListState = ref.watch(chatListNotifierProvider); + + return Scaffold( + backgroundColor: const Color(0xFF1D212C), + appBar: const CustomAppBar(), + body: Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), + decoration: BoxDecoration( + color: const Color(0xFF303544), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Chats', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + fontFamily: GoogleFonts.robotoCondensed().fontFamily, + ), + ), + ), + Expanded( + child: _buildBody(chatListState), + ), + const BottomNavBar(), + ], + ), + ), + ); + } + + Widget _buildBody(ChatListState state) { + switch (state.status) { + case ChatListStatus.loading: + return const Center(child: CircularProgressIndicator()); + case ChatListStatus.loaded: + if (state.chats.isEmpty) { + return const Center( + child: Text('No chats available', + style: TextStyle(color: Colors.white))); + } + return ListView.builder( + itemCount: state.chats.length, + itemBuilder: (context, index) { + return ChatListItem(chat: state.chats[index]); + }, + ); + case ChatListStatus.error: + return Center( + child: Text( + state.errorMessage ?? 'An error occurred', + style: const TextStyle(color: Colors.red), + ), + ); + case ChatListStatus.empty: + return const Center( + child: Text('No chats available', + style: TextStyle(color: Colors.white))); + } + } +} + +class ChatListItem extends StatelessWidget { + final NostrEvent chat; + + const ChatListItem({super.key, required this.chat}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFF1D212C), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + CircleAvatar( + backgroundColor: Colors.grey, + child: Text( + chat.pubkey.isNotEmpty ? chat.pubkey[0] : '?', + style: const TextStyle(color: Colors.white), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + chat.pubkey, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + Text( + chat.createdAt!.toIso8601String(), + style: const TextStyle(color: Colors.grey), + ), + ], + ), + const SizedBox(height: 4), + Text( + chat.content!, + style: const TextStyle(color: Colors.grey), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (chat.isVerified()) + Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Color(0xFF8CC541), + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/notifiers/home_notifier.dart b/lib/features/home/notifiers/home_notifier.dart index 6b5cd775..3ddcddf8 100644 --- a/lib/features/home/notifiers/home_notifier.dart +++ b/lib/features/home/notifiers/home_notifier.dart @@ -54,7 +54,6 @@ class HomeNotifier extends AsyncNotifier { if (currentState == null) return []; final mostroRepository = ref.watch(mostroRepositoryProvider); return orders - .where((order) => mostroRepository.getOrderById(order.orderId!) == null) .where((order) => type == OrderType.buy ? order.orderType == OrderType.buy : order.orderType == OrderType.sell) diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 244cb1ad..f27c6a86 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -18,8 +18,7 @@ class AbstractOrderNotifier extends StateNotifier { this.orderId, this.ref, Action action, - ) : super(orderRepository.getOrderById(orderId) ?? - MostroMessage(action: action, id: orderId)); + ) : super(MostroMessage(action: action, id: orderId)); Future subscribe(Stream stream) async { try { diff --git a/lib/features/take_order/screens/add_lightning_invoice_screen.dart b/lib/features/take_order/screens/add_lightning_invoice_screen.dart index 4b77a2e1..04b29730 100644 --- a/lib/features/take_order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/add_lightning_invoice_screen.dart @@ -3,120 +3,174 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/app/app_theme.dart'; import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/features/take_order/providers/order_notifier_providers.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; -import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart' as action; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; -class AddLightningInvoiceScreen extends ConsumerWidget { +class AddLightningInvoiceScreen extends ConsumerStatefulWidget { final String orderId; - final int sats = 0; const AddLightningInvoiceScreen({super.key, required this.orderId}); @override - Widget build(BuildContext context, WidgetRef ref) { - final mostroRepo = ref.read(mostroRepositoryProvider); - final message = mostroRepo.getOrderById(orderId); - final order = message?.getPayload(); - final amount = order?.amount; + ConsumerState createState() => + _AddLightningInvoiceScreenState(); +} + +class _AddLightningInvoiceScreenState + extends ConsumerState { + final TextEditingController invoiceController = TextEditingController(); - final TextEditingController invoiceController = TextEditingController(); + Future? _orderFuture; + @override + void initState() { + super.initState(); + // Kick off async load from OrderRepository + final orderRepo = ref.read(orderRepositoryProvider); + _orderFuture = orderRepo.getOrderById(widget.orderId) as Future?; + } + + @override + Widget build(BuildContext context) { return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar(title: 'Add Lightning Invoice'), - body: CustomCard( - padding: const EdgeInsets.all(16), - child: Material( - color: Colors.transparent, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Please enter a Lightning Invoice for $amount sats:", - style: TextStyle(color: AppTheme.cream1, fontSize: 16), + body: FutureBuilder( + future: _orderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + // Still loading + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + // Show error message + return Center( + child: Text( + 'Failed to load order: ${snapshot.error}', + style: const TextStyle(color: Colors.red), ), - const SizedBox(height: 8), - TextFormField( - controller: invoiceController, - style: const TextStyle(color: AppTheme.cream1), - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - labelText: "Lightning Invoice", - labelStyle: const TextStyle(color: AppTheme.grey2), - hintText: "Enter invoice here", - hintStyle: const TextStyle(color: AppTheme.grey2), - filled: true, - fillColor: AppTheme.dark1, + ); + } else { + // Data is loaded (or null if not found) + final order = snapshot.data; + if (order == null) { + return const Center( + child: Text( + 'Order not found', + style: TextStyle(color: Colors.white), ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () { - mostroRepo.cancelOrder(orderId); - try { - if (message == null) { - context.go('/'); - return; - } - - // Dispose notifier first - switch (message.action) { - case action.Action.takeBuy: - ref.read(takeBuyOrderNotifierProvider(orderId).notifier).dispose(); - break; - case action.Action.takeSell: - ref.read(takeSellOrderNotifierProvider(orderId).notifier).dispose(); - break; - default: - // Log unexpected action type - break; - } - - // Then cancel order - mostroRepo.cancelOrder(orderId); - context.go('/'); - } catch (e) { - // Show error to user - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to cancel order: ${e.toString()}')), - ); - } - context.go('/'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - ), - child: const Text('CANCEL'), + ); + } + + // Now we have the order, we can safely reference order.amount, etc. + final amount = order.amount; + + return CustomCard( + padding: const EdgeInsets.all(16), + child: Material( + color: Colors.transparent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Please enter a Lightning Invoice for $amount sats:", + style: TextStyle(color: AppTheme.cream1, fontSize: 16), ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton( - onPressed: () { - final invoice = invoiceController.text.trim(); - if (invoice.isNotEmpty) { - mostroRepo.sendInvoice(orderId, invoice); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, + const SizedBox(height: 8), + TextFormField( + controller: invoiceController, + style: const TextStyle(color: AppTheme.cream1), + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + labelText: "Lightning Invoice", + labelStyle: const TextStyle(color: AppTheme.grey2), + hintText: "Enter invoice here", + hintStyle: const TextStyle(color: AppTheme.grey2), + filled: true, + fillColor: AppTheme.dark1, ), - child: const Text('SUBMIT'), ), - ), - ], + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () async { + // Cancel the order + final orderRepo = ref.read(orderRepositoryProvider); + try { + await orderRepo.deleteOrder(order.id!); + if (!mounted) return; + context.go('/'); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to cancel order: ${e.toString()}'), + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('CANCEL'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () async { + final invoice = invoiceController.text.trim(); + if (invoice.isNotEmpty) { + final orderRepo = ref.read(orderRepositoryProvider); + try { + // Typically you'd do something like + // order.buyerInvoice = invoice; + // orderRepo.updateOrder(order) + // or a specialized method orderRepo.sendInvoice + // For this example, let's just do an "update" + + final updated = order.copyWith( + buyerInvoice: invoice, + ); + await orderRepo.updateOrder(updated); + + // If you want to navigate away or confirm + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Lightning Invoice updated!'), + ), + ); + context.go('/'); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to update invoice: ${e.toString()}'), + ), + ); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('SUBMIT'), + ), + ), + ], + ), + ], + ), ), - ], - ), - ), + ); + } + }, ), ); } diff --git a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart b/lib/features/take_order/screens/pay_lightning_invoice_screen.dart index f0b11361..755e7aab 100644 --- a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/pay_lightning_invoice_screen.dart @@ -1,36 +1,71 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; // For Clipboard import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/data/models/payment_request.dart'; +import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; -import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import 'package:flutter/services.dart'; // For Clipboard -class PayLightningInvoiceScreen extends ConsumerWidget { +class PayLightningInvoiceScreen extends ConsumerStatefulWidget { final String orderId; const PayLightningInvoiceScreen({super.key, required this.orderId}); @override - Widget build(BuildContext context, WidgetRef ref) { - final mostroRepo = ref.read(mostroRepositoryProvider); - final message = mostroRepo.getOrderById(orderId); - final pr = message?.getPayload(); + ConsumerState createState() => + _PayLightningInvoiceScreenState(); +} + +class _PayLightningInvoiceScreenState + extends ConsumerState { + Future? _orderFuture; + + @override + void initState() { + super.initState(); + // Kick off async load from the Encrypted DB + final orderRepo = ref.read(orderRepositoryProvider); + _orderFuture = orderRepo.getOrderById(widget.orderId) as Future?; + } + @override + Widget build(BuildContext context) { return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar(title: 'Pay Lightning Invoice'), - body: pr == null || pr.lnInvoice == null - ? Center( + body: FutureBuilder( + future: _orderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + // Show a loading indicator while fetching + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + // Error retrieving order + return Center( child: Text( - 'Invalid payment request.', - style: TextStyle(color: Colors.white, fontSize: 18), + 'Failed to load order: ${snapshot.error}', + style: const TextStyle(color: Colors.red), ), - ) - : SingleChildScrollView( + ); + } else { + final order = snapshot.data; + // If the order isn't found or buyerInvoice is null/empty + if (order == null || order.buyerInvoice == null || order.buyerInvoice!.isEmpty) { + return const Center( + child: Text( + 'Invalid payment request.', + style: TextStyle(color: Colors.white, fontSize: 18), + ), + ); + } + + // We have a valid LN invoice in order.buyerInvoice + final lnInvoice = order.buyerInvoice!; + + return SingleChildScrollView( padding: const EdgeInsets.all(16), child: CustomCard( padding: const EdgeInsets.all(16), @@ -47,7 +82,7 @@ class PayLightningInvoiceScreen extends ConsumerWidget { padding: const EdgeInsets.all(8.0), color: Colors.white, child: QrImageView( - data: pr.lnInvoice!, + data: lnInvoice, version: QrVersions.auto, size: 250.0, backgroundColor: Colors.white, @@ -64,7 +99,7 @@ class PayLightningInvoiceScreen extends ConsumerWidget { const SizedBox(height: 20), ElevatedButton.icon( onPressed: () { - Clipboard.setData(ClipboardData(text: pr.lnInvoice!)); + Clipboard.setData(ClipboardData(text: lnInvoice)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Invoice copied to clipboard!'), @@ -103,9 +138,23 @@ class PayLightningInvoiceScreen extends ConsumerWidget { ), const SizedBox(height: 20), ElevatedButton( - onPressed: () { - mostroRepo.cancelOrder(orderId); - context.go('/'); + onPressed: () async { + final orderRepo = ref.read(orderRepositoryProvider); + try { + // We assume "cancel order" means deleting from DB + if (order.id != null) { + await orderRepo.deleteOrder(order.id!); + } + if (!mounted) return; + context.go('/'); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to cancel order: ${e.toString()}'), + ), + ); + } }, style: ElevatedButton.styleFrom( backgroundColor: Colors.red, @@ -118,7 +167,10 @@ class PayLightningInvoiceScreen extends ConsumerWidget { ], ), ), - ), + ); + } + }, + ), ); } } diff --git a/lib/features/take_order/screens/take_buy_order_screen.dart b/lib/features/take_order/screens/take_buy_order_screen.dart index f41ea49e..5941c50e 100644 --- a/lib/features/take_order/screens/take_buy_order_screen.dart +++ b/lib/features/take_order/screens/take_buy_order_screen.dart @@ -1,10 +1,10 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/app/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/features/take_order/providers/order_notifier_providers.dart'; +import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/features/take_order/widgets/buyer_info.dart'; import 'package:mostro_mobile/features/take_order/widgets/seller_info.dart'; @@ -14,59 +14,75 @@ import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; +import 'package:mostro_mobile/features/take_order/providers/order_notifier_providers.dart'; + class TakeBuyOrderScreen extends ConsumerWidget { final String orderId; + + // Keep text controllers here final TextEditingController _satsAmountController = TextEditingController(); - final TextEditingController _lndAdrress = TextEditingController(); + final TextEditingController _lndAddressController = TextEditingController(); TakeBuyOrderScreen({super.key, required this.orderId}); @override Widget build(BuildContext context, WidgetRef ref) { - final initialOrder = ref.watch(eventProvider(orderId)); + // 1) Watch asynchronous order fetch + final orderAsyncValue = ref.watch(eventProvider(orderId)); return Scaffold( backgroundColor: AppTheme.dark1, - appBar: OrderAppBar( - title: - '${initialOrder?.orderType == OrderType.buy ? "SELL" : "BUY"} BITCOIN'), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - CustomCard( - padding: EdgeInsets.all(16), - child: SellerInfo(order: initialOrder!)), - const SizedBox(height: 16), - _buildSellerAmount(ref), - const SizedBox(height: 16), - ExchangeRateWidget(currency: initialOrder.currency!), - const SizedBox(height: 16), - CustomCard( + appBar: OrderAppBar(title: 'TAKE BUY ORDER'), + body: orderAsyncValue.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center(child: Text('Error: $error')), + data: (order) { + // If order is null => show "Order not found" + if (order == null) { + return const Center(child: Text('Order not found')); + } + + // Build the main UI with the order + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + CustomCard( padding: const EdgeInsets.all(16), - child: BuyerInfo(order: initialOrder)), - const SizedBox(height: 16), - _buildBuyerAmount(initialOrder.amount!), - const SizedBox(height: 16), - _buildLnAddress(), - const SizedBox(height: 16), - _buildActionButtons(context, ref), - ], - ), - ), + child: SellerInfo(order: order), + ), + const SizedBox(height: 16), + _buildSellerAmount(ref, order), + const SizedBox(height: 16), + // Exchange rate widget + if (order.currency != null) + ExchangeRateWidget(currency: order.currency!), + const SizedBox(height: 16), + CustomCard( + padding: const EdgeInsets.all(16), + child: BuyerInfo(order: order), + ), + const SizedBox(height: 16), + _buildBuyerAmount(int.tryParse(order.amount!)), + const SizedBox(height: 16), + _buildLnAddress(), + const SizedBox(height: 16), + _buildActionButtons(context, ref, order.id), + ], + ), + ); + }, ), ); } - Widget _buildSellerAmount(WidgetRef ref) { - final initialOrder = ref.read(eventProvider(orderId)); - final exchangeRateAsyncValue = - ref.watch(exchangeRateProvider(initialOrder!.currency!)); + Widget _buildSellerAmount(WidgetRef ref, NostrEvent order) { + final exchangeRateAsyncValue = ref.watch(exchangeRateProvider(order.currency!)); return exchangeRateAsyncValue.when( loading: () => const CircularProgressIndicator(), - error: (error, _) => Text('Error: $error'), + error: (error, _) => Text('Exchange rate error: $error'), data: (exchangeRate) { + // Example usage: exchangeRate might be a double or something return CustomCard( padding: const EdgeInsets.all(16), child: Row( @@ -75,13 +91,17 @@ class TakeBuyOrderScreen extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${initialOrder.fiatAmount} ${initialOrder.currency} (${initialOrder.premium}%)', - style: const TextStyle( - color: AppTheme.cream1, - fontSize: 18, - fontWeight: FontWeight.bold)), - Text('${initialOrder.amount} sats', - style: const TextStyle(color: AppTheme.grey2)), + '${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), + ), ], ) ], @@ -91,7 +111,8 @@ class TakeBuyOrderScreen extends ConsumerWidget { ); } - Widget _buildBuyerAmount(String amount) { + Widget _buildBuyerAmount(int? amount) { + final safeAmount = amount ?? 0; return CustomCard( padding: const EdgeInsets.all(16), child: Column( @@ -99,7 +120,7 @@ class TakeBuyOrderScreen extends ConsumerWidget { children: [ CurrencyTextField(controller: _satsAmountController, label: 'Sats'), const SizedBox(height: 8), - Text('\$ $amount', style: const TextStyle(color: AppTheme.grey2)), + Text('\$ $safeAmount', style: const TextStyle(color: AppTheme.grey2)), const SizedBox(height: 24), ], ), @@ -109,39 +130,32 @@ class TakeBuyOrderScreen extends ConsumerWidget { Widget _buildLnAddress() { return CustomCard( padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: AppTheme.dark1, - borderRadius: BorderRadius.circular(8), - ), - child: TextFormField( - controller: _lndAdrress, - style: const TextStyle(color: AppTheme.cream1), - decoration: InputDecoration( - border: InputBorder.none, - labelText: "Enter a Lightning Address", - labelStyle: const TextStyle(color: AppTheme.grey2), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a value'; - } - return null; - }, - ), - ), - ], + child: TextFormField( + controller: _lndAddressController, + style: const TextStyle(color: AppTheme.cream1), + decoration: InputDecoration( + border: InputBorder.none, + labelText: "Enter a Lightning Address", + labelStyle: const TextStyle(color: AppTheme.grey2), + filled: true, + fillColor: AppTheme.dark1, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a value'; + } + return null; + }, ), ); } - Widget _buildActionButtons(BuildContext context, WidgetRef ref) { + Widget _buildActionButtons(BuildContext context, WidgetRef ref, String? orderId) { + // If there's no orderId, hide the button or handle it + final realOrderId = orderId ?? ''; + final orderDetailsNotifier = - ref.read(takeBuyOrderNotifierProvider(orderId).notifier); + ref.read(takeBuyOrderNotifierProvider(realOrderId).notifier); return Row( mainAxisAlignment: MainAxisAlignment.end, @@ -157,7 +171,16 @@ class TakeBuyOrderScreen extends ConsumerWidget { ), const SizedBox(width: 16), ElevatedButton( - onPressed: () => orderDetailsNotifier.takeBuyOrder(orderId, null), + onPressed: () { + // Possibly pass the LN address or sats from the text fields + final satsText = _satsAmountController.text; + final lndAddress = _lndAddressController.text.trim(); + // Convert satsText to int if needed + final satsAmount = int.tryParse(satsText); + + orderDetailsNotifier.takeBuyOrder(realOrderId, satsAmount); + // Could also pass the LN address if your method expects it + }, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.mostroGreen, ), diff --git a/lib/features/take_order/screens/take_sell_order_screen.dart b/lib/features/take_order/screens/take_sell_order_screen.dart index e1f59a3c..1d08b3a5 100644 --- a/lib/features/take_order/screens/take_sell_order_screen.dart +++ b/lib/features/take_order/screens/take_sell_order_screen.dart @@ -1,3 +1,4 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -45,45 +46,52 @@ class TakeSellOrderScreen extends ConsumerWidget { } Widget _buildContent(BuildContext context, WidgetRef ref) { - final initialOrder = ref.read(eventProvider(orderId)); + final orderAsyncValue = ref.read(eventProvider(orderId)); return Scaffold( backgroundColor: AppTheme.dark1, - appBar: OrderAppBar( - title: - '${initialOrder?.orderType == OrderType.buy ? "SELL" : "BUY"} BITCOIN'), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - CustomCard( - padding: EdgeInsets.all(16), - child: SellerInfo(order: initialOrder!)), - const SizedBox(height: 16), - _buildSellerAmount(ref), - const SizedBox(height: 16), - ExchangeRateWidget(currency: initialOrder.currency!), - const SizedBox(height: 16), - CustomCard( - padding: const EdgeInsets.all(16), - child: BuyerInfo(order: initialOrder)), - const SizedBox(height: 16), - _buildBuyerAmount(initialOrder.amount!), - const SizedBox(height: 16), - _buildLnAddress(), - const SizedBox(height: 16), - _buildActionButtons(context, ref), - ], - ), - ), + appBar: OrderAppBar(title: 'SELL BITCOIN'), + body: orderAsyncValue.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center(child: Text('Error: $error')), + data: (initialOrder) { + // If order is null => show "Order not found" + if (initialOrder == null) { + return const Center(child: Text('Order not found')); + } + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + CustomCard( + padding: EdgeInsets.all(16), + child: SellerInfo(order: initialOrder)), + const SizedBox(height: 16), + _buildSellerAmount(ref, initialOrder), + const SizedBox(height: 16), + ExchangeRateWidget(currency: initialOrder.currency!), + const SizedBox(height: 16), + CustomCard( + padding: const EdgeInsets.all(16), + child: BuyerInfo(order: initialOrder)), + const SizedBox(height: 16), + _buildBuyerAmount(initialOrder.amount!), + const SizedBox(height: 16), + _buildLnAddress(), + const SizedBox(height: 16), + _buildActionButtons(context, ref), + ], + ), + ), + ); + }, ), ); } - Widget _buildSellerAmount(WidgetRef ref) { - final initialOrder = ref.read(eventProvider(orderId)); + Widget _buildSellerAmount(WidgetRef ref, NostrEvent initialOrder) { final exchangeRateAsyncValue = - ref.watch(exchangeRateProvider(initialOrder!.currency!)); + ref.watch(exchangeRateProvider(initialOrder.currency!)); return exchangeRateAsyncValue.when( loading: () => const CircularProgressIndicator(), error: (error, _) => Text('Error: $error'), @@ -122,71 +130,71 @@ class TakeSellOrderScreen extends ConsumerWidget { final val = order?.amount; return Scaffold( backgroundColor: AppTheme.dark1, - appBar: OrderAppBar( - title: - 'Add a Lightning Invoice'), + appBar: OrderAppBar(title: 'Add a Lightning Invoice'), body: CustomCard( - padding: const EdgeInsets.all(16), - child: Material( - color: Colors.transparent, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Please enter a Lightning Invoice for $val sats:", - style: TextStyle(color: AppTheme.cream1, fontSize: 16), - ), - const SizedBox(height: 8), - TextFormField( - controller: invoiceController, - style: const TextStyle(color: AppTheme.cream1), - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + padding: const EdgeInsets.all(16), + child: Material( + color: Colors.transparent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Please enter a Lightning Invoice for $val sats:", + style: TextStyle(color: AppTheme.cream1, fontSize: 16), + ), + const SizedBox(height: 8), + TextFormField( + controller: invoiceController, + style: const TextStyle(color: AppTheme.cream1), + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + labelText: "Lightning Invoice", + labelStyle: const TextStyle(color: AppTheme.grey2), + hintText: "Enter invoice here", + hintStyle: const TextStyle(color: AppTheme.grey2), + filled: true, + fillColor: AppTheme.dark1, ), - labelText: "Lightning Invoice", - labelStyle: const TextStyle(color: AppTheme.grey2), - hintText: "Enter invoice here", - hintStyle: const TextStyle(color: AppTheme.grey2), - filled: true, - fillColor: AppTheme.dark1, ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () { - context.go('/'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () { + context.go('/'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('CANCEL'), ), - child: const Text('CANCEL'), ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton( - onPressed: () { - final invoice = invoiceController.text.trim(); - if (invoice.isNotEmpty) { - orderDetailsNotifier.sendInvoice(orderId, invoice, val); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () { + final invoice = invoiceController.text.trim(); + if (invoice.isNotEmpty) { + orderDetailsNotifier.sendInvoice( + orderId, invoice, val); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('SUBMIT'), ), - child: const Text('SUBMIT'), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), - ),); + ); } Widget _buildBuyerAmount(String amount) { diff --git a/lib/presentation/chat_detail/bloc/chat_detail_bloc.dart b/lib/presentation/chat_detail/bloc/chat_detail_bloc.dart deleted file mode 100644 index 05fa277b..00000000 --- a/lib/presentation/chat_detail/bloc/chat_detail_bloc.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'chat_detail_event.dart'; -import 'chat_detail_state.dart'; - -class ChatDetailBloc extends Bloc { - ChatDetailBloc() : super(ChatDetailInitial()) { - on(_onLoadChatDetail); - on(_onSendMessage); - } - - void _onLoadChatDetail(LoadChatDetail event, Emitter emit) { - //TODO: Implementar lógica para cargar los detalles del chat - } - - void _onSendMessage(SendMessage event, Emitter emit) { - //TODO: Implementar lógica para enviar un mensaje - } -} diff --git a/lib/presentation/chat_detail/bloc/chat_detail_event.dart b/lib/presentation/chat_detail/bloc/chat_detail_event.dart deleted file mode 100644 index 1696d7ae..00000000 --- a/lib/presentation/chat_detail/bloc/chat_detail_event.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:equatable/equatable.dart'; - -abstract class ChatDetailEvent extends Equatable { - const ChatDetailEvent(); - - @override - List get props => []; -} - -class LoadChatDetail extends ChatDetailEvent { - final String chatId; - - const LoadChatDetail(this.chatId); - - @override - List get props => [chatId]; -} - -class SendMessage extends ChatDetailEvent { - final String message; - - const SendMessage(this.message); - - @override - List get props => [message]; -} diff --git a/lib/presentation/chat_detail/bloc/chat_detail_state.dart b/lib/presentation/chat_detail/bloc/chat_detail_state.dart deleted file mode 100644 index 20f9e84a..00000000 --- a/lib/presentation/chat_detail/bloc/chat_detail_state.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:equatable/equatable.dart'; - -abstract class ChatDetailState extends Equatable { - const ChatDetailState(); - - @override - List get props => []; -} - -class ChatDetailInitial extends ChatDetailState {} - -class ChatDetailLoading extends ChatDetailState {} - -class ChatDetailLoaded extends ChatDetailState { - final List messages; - - const ChatDetailLoaded(this.messages); - - @override - List get props => [messages]; -} - -class ChatDetailError extends ChatDetailState { - final String error; - - const ChatDetailError(this.error); - - @override - List get props => [error]; -} - -class ChatMessage { - final String id; - final String sender; - final String content; - final DateTime timestamp; - - ChatMessage({ - required this.id, - required this.sender, - required this.content, - required this.timestamp, - }); -} diff --git a/lib/presentation/chat_detail/screens/chat_detail_screen.dart b/lib/presentation/chat_detail/screens/chat_detail_screen.dart deleted file mode 100644 index 5c55db6f..00000000 --- a/lib/presentation/chat_detail/screens/chat_detail_screen.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import '../bloc/chat_detail_bloc.dart'; -import '../bloc/chat_detail_event.dart'; -import '../bloc/chat_detail_state.dart'; -import '../../widgets/bottom_nav_bar.dart'; - -class ChatDetailScreen extends StatelessWidget { - final String chatId; - - const ChatDetailScreen({super.key, required this.chatId}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ChatDetailBloc()..add(LoadChatDetail(chatId)), - child: Scaffold( - backgroundColor: const Color(0xFF1D212C), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - title: const Text('JACK FOOTSEY'), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/'), - ), - ), - body: BlocBuilder( - builder: (context, state) { - if (state is ChatDetailLoading) { - return const Center(child: CircularProgressIndicator()); - } else if (state is ChatDetailLoaded) { - return Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: state.messages.length, - itemBuilder: (context, index) { - final message = state.messages[index]; - return _buildMessageBubble(message); - }, - ), - ), - _buildMessageInput(context), - ], - ); - } else if (state is ChatDetailError) { - return Center(child: Text(state.error)); - } else { - return const Center(child: Text('Something went wrong')); - } - }, - ), - bottomNavigationBar: const BottomNavBar(), - ), - ); - } - - Widget _buildMessageBubble(ChatMessage message) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - alignment: message.sender == 'Mostro' - ? Alignment.centerLeft - : Alignment.centerRight, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: message.sender == 'Mostro' - ? const Color(0xFF303544) - : const Color(0xFF8CC541), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - message.content, - style: const TextStyle(color: Colors.white), - ), - ), - ); - } - - Widget _buildMessageInput(BuildContext context) { - final TextEditingController controller = TextEditingController(); - return Container( - padding: const EdgeInsets.all(8), - color: const Color(0xFF303544), - child: Row( - children: [ - Expanded( - child: TextField( - controller: controller, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - hintText: 'Type a message...', - hintStyle: const TextStyle(color: Colors.grey), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: const Color(0xFF1D212C), - ), - ), - ), - IconButton( - icon: const Icon(Icons.send, color: Color(0xFF8CC541)), - onPressed: () { - if (controller.text.isNotEmpty) { - context - .read() - .add(SendMessage(controller.text)); - controller.clear(); - } - }, - ), - ], - ), - ); - } -} diff --git a/lib/presentation/chat_list/bloc/chat_list_bloc.dart b/lib/presentation/chat_list/bloc/chat_list_bloc.dart deleted file mode 100644 index 54f8ef77..00000000 --- a/lib/presentation/chat_list/bloc/chat_list_bloc.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:mostro_mobile/data/models/chat_model.dart'; -import 'chat_list_event.dart'; -import 'chat_list_state.dart'; - -class ChatListBloc extends Bloc { - ChatListBloc() : super(const ChatListState()) { - on(_onLoadChatList); - } - - void _onLoadChatList(LoadChatList event, Emitter emit) { - emit(state.copyWith(status: ChatListStatus.loading)); - - // Simulamos la carga de chats (reemplaza esto con una llamada real a tu repositorio o API) - final chats = [ - ChatModel( - id: '1', - username: 'Alice', - lastMessage: 'Hey, are you still interested in the trade?', - timeAgo: '5m ago', - isUnread: true, - ), - ChatModel( - id: '2', - username: 'Bob', - lastMessage: 'Thanks for the trade!', - timeAgo: '2h ago', - ), - ChatModel( - id: '3', - username: 'Charlie', - lastMessage: "I've sent the payment. Please confirm.", - timeAgo: '1d ago', - ), - ]; - - emit(state.copyWith( - status: ChatListStatus.loaded, - chats: chats, - )); - } -} diff --git a/lib/presentation/chat_list/bloc/chat_list_event.dart b/lib/presentation/chat_list/bloc/chat_list_event.dart deleted file mode 100644 index 970ea1d5..00000000 --- a/lib/presentation/chat_list/bloc/chat_list_event.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:equatable/equatable.dart'; - -abstract class ChatListEvent extends Equatable { - const ChatListEvent(); - - @override - List get props => []; -} - -class LoadChatList extends ChatListEvent {} \ No newline at end of file diff --git a/lib/presentation/chat_list/screens/chat_list_screen.dart b/lib/presentation/chat_list/screens/chat_list_screen.dart deleted file mode 100644 index 2171061c..00000000 --- a/lib/presentation/chat_list/screens/chat_list_screen.dart +++ /dev/null @@ -1,141 +0,0 @@ -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'; -import 'package:mostro_mobile/presentation/chat_list/bloc/chat_list_state.dart'; -import 'package:mostro_mobile/presentation/widgets/bottom_nav_bar.dart'; -import 'package:mostro_mobile/presentation/widgets/custom_app_bar.dart'; - -class ChatListScreen extends StatelessWidget { - const ChatListScreen({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ChatListBloc()..add(LoadChatList()), - child: Scaffold( - backgroundColor: const Color(0xFF1D212C), - appBar: const CustomAppBar(), - body: Container( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), - decoration: BoxDecoration( - color: const Color(0xFF303544), - borderRadius: BorderRadius.circular(20), - ), - child: Column( - children: [ - Padding( - padding: EdgeInsets.all(16.0), - child: Text( - 'Chats', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - fontFamily: GoogleFonts.robotoCondensed().fontFamily, - ), - ), - ), - Expanded( - child: BlocBuilder( - builder: (context, state) { - if (state.status == ChatListStatus.loading) { - return const Center(child: CircularProgressIndicator()); - } else if (state.status == ChatListStatus.loaded) { - return ListView.builder( - itemCount: state.chats.length, - itemBuilder: (context, index) { - return ChatListItem(chat: state.chats[index]); - }, - ); - } else if (state.status == ChatListStatus.error) { - return Center( - child: - Text(state.errorMessage ?? 'An error occurred')); - } else { - return const Center(child: Text('No chats available')); - } - }, - ), - ), - const BottomNavBar(), - ], - ), - ), - ), - ); - } -} - -class ChatListItem extends StatelessWidget { - final ChatModel chat; - - const ChatListItem({super.key, required this.chat}); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - decoration: BoxDecoration( - color: const Color(0xFF1D212C), - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - CircleAvatar( - backgroundColor: Colors.grey, - child: Text( - chat.username[0], - style: const TextStyle(color: Colors.white), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - chat.username, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - Text( - chat.timeAgo, - style: const TextStyle(color: Colors.grey), - ), - ], - ), - const SizedBox(height: 4), - Text( - chat.lastMessage, - style: const TextStyle(color: Colors.grey), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - if (chat.isUnread) - Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Color(0xFF8CC541), - shape: BoxShape.circle, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index e7956f4a..10cc7b60 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -1,6 +1,6 @@ import 'package:dart_nostr/dart_nostr.dart'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/app/config.dart'; -import 'package:logging/logging.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class NostrService { @@ -8,7 +8,7 @@ class NostrService { factory NostrService() => _instance; NostrService._internal(); - final Logger _logger = Logger('NostrService'); + final Logger _logger = Logger(); late Nostr _nostr; bool _isInitialized = false; @@ -21,16 +21,16 @@ class NostrService { relaysUrl: Config.nostrRelays, connectionTimeout: Config.nostrConnectionTimeout, onRelayListening: (relay, url, channel) { - _logger.info('Connected to relay: $url'); + _logger.i('Connected to relay: $url'); }, onRelayConnectionError: (relay, error, channel) { - _logger.warning('Failed to connect to relay $relay: $error'); + _logger.w('Failed to connect to relay $relay: $error'); }, ); _isInitialized = true; - _logger.info('Nostr initialized successfully'); + _logger.i('Nostr initialized successfully'); } catch (e) { - _logger.severe('Failed to initialize Nostr: $e'); + _logger.e('Failed to initialize Nostr: $e'); rethrow; } } @@ -43,9 +43,9 @@ class NostrService { try { await _nostr.relaysService.sendEventToRelaysAsync(event, timeout: Config.nostrConnectionTimeout); - _logger.info('Event published successfully'); + _logger.i('Event published successfully'); } catch (e) { - _logger.warning('Failed to publish event: $e'); + _logger.w('Failed to publish event: $e'); rethrow; } } @@ -67,7 +67,7 @@ class NostrService { await _nostr.relaysService.disconnectFromRelays(); _isInitialized = false; - _logger.info('Disconnected from all relays'); + _logger.i('Disconnected from all relays'); } bool get isInitialized => _isInitialized; diff --git a/lib/shared/providers/order_repository_provider.dart b/lib/shared/providers/order_repository_provider.dart index 190b6a2b..0611ee2f 100644 --- a/lib/shared/providers/order_repository_provider.dart +++ b/lib/shared/providers/order_repository_provider.dart @@ -15,7 +15,7 @@ final orderEventsProvider = StreamProvider>((ref) { return orderRepository.eventsStream; }); -final eventProvider = Provider.family((ref, orderId) { +final eventProvider = FutureProvider.family((ref, orderId) { final repository = ref.watch(orderRepositoryProvider); return repository.getOrderById(orderId); }); \ No newline at end of file diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 86135f19..00cea366 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,14 +5,16 @@ import FlutterMacOS import Foundation -import flutter_secure_storage_macos +import flutter_secure_storage_darwin import local_auth_darwin import path_provider_foundation import shared_preferences_foundation +import sqflite_sqlcipher func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqfliteSqlCipherPlugin.register(with: registry.registrar(forPlugin: "SqfliteSqlCipherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 6aa6c99f..ac78dcec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -26,10 +26,10 @@ packages: dependency: transitive description: name: archive - sha256: "08064924cbf0ab88280a0c3f60db9dd24fec693927e725ecb176f16c629d1cb8" + sha256: "6199c74e3db4fbfbd04f66d739e72fe11c8a8957d5f219f1f4482dbde6420b5a" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.0.2" args: dependency: transitive description: @@ -380,10 +380,10 @@ packages: dependency: "direct main" description: name: flutter_launcher_icons - sha256: "31cd0885738e87c72d6f055564d37fabcdacee743b396b78c7636c169cac64f5" + sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c url: "https://pub.dev" source: hosted - version: "0.14.2" + version: "0.14.3" flutter_lints: dependency: "direct dev" description: @@ -401,10 +401,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398" + sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" url: "https://pub.dev" source: hosted - version: "2.0.23" + version: "2.0.24" flutter_riverpod: dependency: "direct main" description: @@ -416,61 +416,59 @@ packages: flutter_secure_storage: dependency: "direct main" description: - path: flutter_secure_storage - ref: develop - resolved-ref: f6b2a15047cc3aff7e78762428e509654a59bc45 - url: "https://github.com/chebizarro/flutter_secure_storage.git" - source: git - version: "9.2.3" - flutter_secure_storage_linux: + name: flutter_secure_storage + sha256: f7eceb0bc6f4fd0441e29d43cab9ac2a1c5ffd7ea7b64075136b718c46954874 + url: "https://pub.dev" + source: hosted + version: "10.0.0-beta.4" + flutter_secure_storage_darwin: dependency: transitive description: - name: flutter_secure_storage_linux - sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" + name: flutter_secure_storage_darwin + sha256: f226f2a572bed96bc6542198ebaec227150786e34311d455a7e2d3d06d951845 url: "https://pub.dev" source: hosted - version: "1.2.1" - flutter_secure_storage_macos: + version: "0.1.0" + flutter_secure_storage_linux: dependency: transitive description: - name: flutter_secure_storage_macos - sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81" + name: flutter_secure_storage_linux + sha256: "9b4b73127e857cd3117d43a70fa3dddadb6e0b253be62e6a6ab85caa0742182c" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "2.0.1" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "2.0.1" flutter_secure_storage_web: dependency: transitive description: - path: flutter_secure_storage_web - ref: develop - resolved-ref: f6b2a15047cc3aff7e78762428e509654a59bc45 - url: "https://github.com/chebizarro/flutter_secure_storage.git" - source: git - version: "2.0.0-beta.2" + name: flutter_secure_storage_web + sha256: "4c3f233e739545c6cb09286eeec1cc4744138372b985113acc904f7263bef517" + url: "https://pub.dev" + source: hosted + version: "2.0.0" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + sha256: ff32af20f70a8d0e59b2938fc92de35b54a74671041c814275afd80e27df9f21 url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "4.0.0" flutter_svg: dependency: transitive description: name: flutter_svg - sha256: "54900a1a1243f3c4a5506d853a2b5c2dbc38d5f27e52a52618a8054401431123" + sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b url: "https://pub.dev" source: hosted - version: "2.0.16" + version: "2.0.17" flutter_test: dependency: "direct dev" description: flutter @@ -498,18 +496,18 @@ packages: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" go_router: dependency: "direct main" description: name: go_router - sha256: "2fd11229f59e23e967b0775df8d5948a519cd7e1e8b6e849729e010587b46539" + sha256: daf3ff5570f55396b2d2c9bf8136d7db3a8acf208ac0cef92a3ae2beb9a81550 url: "https://pub.dev" source: hosted - version: "14.6.2" + version: "14.7.1" google_fonts: dependency: "direct main" description: @@ -554,18 +552,18 @@ packages: dependency: "direct main" description: name: http - sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.3.0" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: @@ -578,10 +576,10 @@ packages: dependency: transitive description: name: image - sha256: "20842a5ad1555be624c314b0c0cc0566e8ece412f61e859a42efeb6d4101a26c" + sha256: "8346ad4b5173924b5ddddab782fc7d8a6300178c8b1dc427775405a01701c4a6" url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "4.5.2" integration_test: dependency: "direct dev" description: flutter @@ -671,10 +669,10 @@ packages: dependency: transitive description: name: local_auth_darwin - sha256: "6d2950da311d26d492a89aeb247c72b4653ddc93601ea36a84924a396806d49c" + sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2" url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.3" local_auth_platform_interface: dependency: transitive description: @@ -691,8 +689,16 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.11" - logging: + logger: dependency: "direct main" + description: + name: logger + sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + url: "https://pub.dev" + source: hosted + version: "2.5.0" + logging: + dependency: transitive description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 @@ -776,12 +782,12 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" @@ -912,10 +918,10 @@ packages: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" pubspec_parse: dependency: transitive description: @@ -952,26 +958,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82" + sha256: c59819dacc6669a1165d54d2735a9543f136f9b3cec94ca65cea6ab8dffc422e url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.0" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "7f172d1b06de5da47b6264c2692ee2ead20bbbc246690427cdb4fc301cd0c549" + sha256: "986dc7b7d14f38064bfa85ace28df1f1a66d4fba32e4b1079d4ea537d9541b01" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.4.3" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d" + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4" shared_preferences_linux: dependency: transitive description: @@ -1061,10 +1067,10 @@ packages: dependency: transitive description: name: source_maps - sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" url: "https://pub.dev" source: hosted - version: "0.10.12" + version: "0.10.13" source_span: dependency: transitive description: @@ -1081,6 +1087,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + url: "https://pub.dev" + source: hosted + version: "2.5.4+6" + sqflite_sqlcipher: + dependency: "direct main" + description: + name: sqflite_sqlcipher + sha256: "16033fde6c7d7bd657b71a2bc42332ab02bc8001c3212f502d2e02714e735ec9" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" stack_trace: dependency: transitive description: @@ -1129,6 +1151,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" term_glyph: dependency: transitive description: @@ -1205,10 +1235,10 @@ packages: dependency: transitive description: name: vector_graphics_codec - sha256: "2430b973a4ca3c4dbc9999b62b8c719a160100dcbae5c819bae0cacce32c9cdb" + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "1.1.12" + version: "1.1.13" vector_graphics_compiler: dependency: transitive description: @@ -1237,10 +1267,10 @@ packages: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web: dependency: transitive description: @@ -1277,10 +1307,10 @@ packages: dependency: transitive description: name: win32 - sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69" + sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.10.0" xdg_directories: dependency: transitive description: @@ -1301,10 +1331,10 @@ packages: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: dart: ">=3.5.4 <=3.9.9" flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index 69c9b35e..6d6ec916 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,7 +42,7 @@ dependencies: convert: ^3.1.1 shared_preferences: ^2.3.3 equatable: ^2.0.5 - logging: ^1.2.0 + logger: ^2.5.0 local_auth: ^2.3.0 google_fonts: ^6.2.1 timeago: ^3.7.0 @@ -61,18 +61,15 @@ dependencies: url: https://github.com/chebizarro/dart-nip44.git ref: master - # temporary fork until main package is updated - flutter_secure_storage: - git: - url: https://github.com/chebizarro/flutter_secure_storage.git - path: flutter_secure_storage - ref: develop + flutter_secure_storage: ^10.0.0-beta.4 + sqflite_sqlcipher: ^3.1.0+1 go_router: ^14.6.2 bip39: ^1.0.6 flutter_hooks: ^0.20.5 hooks_riverpod: ^2.6.1 flutter_launcher_icons: ^0.14.2 bip32: ^2.0.0 + path: ^1.9.0 dev_dependencies: flutter_test: From 4da43b6f3c2e67ae3b81d7b46497c723e63fdbee Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sun, 26 Jan 2025 00:08:10 -0800 Subject: [PATCH 032/149] Rationalised TakeOrderNotifier --- lib/app/app_routes.dart | 16 +- .../notifiers/take_buy_order_notifier.dart | 12 - ...notifier.dart => take_order_notifier.dart} | 10 +- .../providers/order_notifier_providers.dart | 11 +- ...der_screen.dart => take_order_screen.dart} | 35 ++- .../screens/take_sell_order_screen.dart | 276 ------------------ 6 files changed, 43 insertions(+), 317 deletions(-) delete mode 100644 lib/features/take_order/notifiers/take_buy_order_notifier.dart rename lib/features/take_order/notifiers/{take_sell_order_notifier.dart => take_order_notifier.dart} (61%) rename lib/features/take_order/screens/{take_buy_order_screen.dart => take_order_screen.dart} (85%) delete mode 100644 lib/features/take_order/screens/take_sell_order_screen.dart diff --git a/lib/app/app_routes.dart b/lib/app/app_routes.dart index 02a34cb5..4e799727 100644 --- a/lib/app/app_routes.dart +++ b/lib/app/app_routes.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/features/add_order/screens/add_order_screen.dart'; import 'package:mostro_mobile/features/add_order/screens/order_confirmation_screen.dart'; import 'package:mostro_mobile/features/auth/screens/welcome_screen.dart'; @@ -8,8 +9,7 @@ import 'package:mostro_mobile/features/home/screens/home_screen.dart'; import 'package:mostro_mobile/features/order_book/screens/order_book_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/add_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/pay_lightning_invoice_screen.dart'; -import 'package:mostro_mobile/features/take_order/screens/take_buy_order_screen.dart'; -import 'package:mostro_mobile/features/take_order/screens/take_sell_order_screen.dart'; +import 'package:mostro_mobile/features/take_order/screens/take_order_screen.dart'; import 'package:mostro_mobile/presentation/profile/screens/profile_screen.dart'; import 'package:mostro_mobile/features/auth/screens/register_screen.dart'; import 'package:mostro_mobile/shared/widgets/navigation_listener_widget.dart'; @@ -57,13 +57,17 @@ final goRouter = GoRouter( ), GoRoute( path: '/take_sell/:orderId', - builder: (context, state) => - TakeSellOrderScreen(orderId: state.pathParameters['orderId']!), + builder: (context, state) => TakeOrderScreen( + orderId: state.pathParameters['orderId']!, + orderType: OrderType.sell, + ), ), GoRoute( path: '/take_buy/:orderId', - builder: (context, state) => - TakeBuyOrderScreen(orderId: state.pathParameters['orderId']!), + builder: (context, state) => TakeOrderScreen( + orderId: state.pathParameters['orderId']!, + orderType: OrderType.buy, + ), ), GoRoute( path: '/order_confirmed/:orderId', diff --git a/lib/features/take_order/notifiers/take_buy_order_notifier.dart b/lib/features/take_order/notifiers/take_buy_order_notifier.dart deleted file mode 100644 index f3635aed..00000000 --- a/lib/features/take_order/notifiers/take_buy_order_notifier.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; - -class TakeBuyOrderNotifier extends AbstractOrderNotifier { - - TakeBuyOrderNotifier(super.orderRepository, super.orderId, super.ref, super.action); - - void takeBuyOrder(String orderId, int? amount) async { - final stream = await orderRepository.takeBuyOrder(orderId, amount); - await subscribe(stream); - } - -} diff --git a/lib/features/take_order/notifiers/take_sell_order_notifier.dart b/lib/features/take_order/notifiers/take_order_notifier.dart similarity index 61% rename from lib/features/take_order/notifiers/take_sell_order_notifier.dart rename to lib/features/take_order/notifiers/take_order_notifier.dart index f7aa3839..9132431f 100644 --- a/lib/features/take_order/notifiers/take_sell_order_notifier.dart +++ b/lib/features/take_order/notifiers/take_order_notifier.dart @@ -1,7 +1,8 @@ import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; -class TakeSellOrderNotifier extends AbstractOrderNotifier { - TakeSellOrderNotifier(super.orderRepository, super.orderId, super.ref, super.action); +class TakeOrderNotifier extends AbstractOrderNotifier { + TakeOrderNotifier( + super.orderRepository, super.orderId, super.ref, super.action); void takeSellOrder(String orderId, int? amount, String? lnAddress) async { final stream = @@ -9,6 +10,11 @@ class TakeSellOrderNotifier extends AbstractOrderNotifier { await subscribe(stream); } + void takeBuyOrder(String orderId, int? amount) async { + final stream = await orderRepository.takeBuyOrder(orderId, amount); + await subscribe(stream); + } + void sendInvoice(String orderId, String invoice, int? amount) async { await orderRepository.sendInvoice(orderId, invoice); } diff --git a/lib/features/take_order/providers/order_notifier_providers.dart b/lib/features/take_order/providers/order_notifier_providers.dart index 2763fb75..51c89040 100644 --- a/lib/features/take_order/providers/order_notifier_providers.dart +++ b/lib/features/take_order/providers/order_notifier_providers.dart @@ -1,20 +1,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/features/take_order/notifiers/take_buy_order_notifier.dart'; -import 'package:mostro_mobile/features/take_order/notifiers/take_sell_order_notifier.dart'; +import 'package:mostro_mobile/features/take_order/notifiers/take_order_notifier.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; final takeSellOrderNotifierProvider = - StateNotifierProvider.family( + StateNotifierProvider.family( (ref, orderId) { final repository = ref.watch(mostroRepositoryProvider); - return TakeSellOrderNotifier(repository, orderId, ref, Action.takeSell); + return TakeOrderNotifier(repository, orderId, ref, Action.takeSell); }); final takeBuyOrderNotifierProvider = - StateNotifierProvider.family( + StateNotifierProvider.family( (ref, orderId) { final repository = ref.watch(mostroRepositoryProvider); - return TakeBuyOrderNotifier(repository, orderId, ref, Action.takeBuy); + return TakeOrderNotifier(repository, orderId, ref, Action.takeBuy); }); diff --git a/lib/features/take_order/screens/take_buy_order_screen.dart b/lib/features/take_order/screens/take_order_screen.dart similarity index 85% rename from lib/features/take_order/screens/take_buy_order_screen.dart rename to lib/features/take_order/screens/take_order_screen.dart index 5941c50e..a736e233 100644 --- a/lib/features/take_order/screens/take_buy_order_screen.dart +++ b/lib/features/take_order/screens/take_order_screen.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/app/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/data/models/order.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/features/take_order/widgets/buyer_info.dart'; import 'package:mostro_mobile/features/take_order/widgets/seller_info.dart'; @@ -13,17 +13,15 @@ import 'package:mostro_mobile/presentation/widgets/exchange_rate_widget.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; - import 'package:mostro_mobile/features/take_order/providers/order_notifier_providers.dart'; -class TakeBuyOrderScreen extends ConsumerWidget { +class TakeOrderScreen extends ConsumerWidget { final String orderId; - - // Keep text controllers here + final OrderType orderType; final TextEditingController _satsAmountController = TextEditingController(); final TextEditingController _lndAddressController = TextEditingController(); - TakeBuyOrderScreen({super.key, required this.orderId}); + TakeOrderScreen({super.key, required this.orderId, required this.orderType}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -32,7 +30,8 @@ class TakeBuyOrderScreen extends ConsumerWidget { return Scaffold( backgroundColor: AppTheme.dark1, - appBar: OrderAppBar(title: 'TAKE BUY ORDER'), + appBar: OrderAppBar( + title: '${orderType == OrderType.buy ? "SELL" : "BUY"} BITCOIN'), body: orderAsyncValue.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => Center(child: Text('Error: $error')), @@ -77,7 +76,8 @@ class TakeBuyOrderScreen extends ConsumerWidget { } Widget _buildSellerAmount(WidgetRef ref, NostrEvent order) { - final exchangeRateAsyncValue = ref.watch(exchangeRateProvider(order.currency!)); + final exchangeRateAsyncValue = + ref.watch(exchangeRateProvider(order.currency!)); return exchangeRateAsyncValue.when( loading: () => const CircularProgressIndicator(), error: (error, _) => Text('Exchange rate error: $error'), @@ -150,12 +150,14 @@ class TakeBuyOrderScreen extends ConsumerWidget { ); } - Widget _buildActionButtons(BuildContext context, WidgetRef ref, String? orderId) { + Widget _buildActionButtons( + BuildContext context, WidgetRef ref, String? orderId) { // If there's no orderId, hide the button or handle it final realOrderId = orderId ?? ''; - final orderDetailsNotifier = - ref.read(takeBuyOrderNotifierProvider(realOrderId).notifier); + final orderDetailsNotifier = orderType == OrderType.sell ? + ref.read(takeSellOrderNotifierProvider(realOrderId).notifier): + ref.read(takeBuyOrderNotifierProvider(realOrderId).notifier); return Row( mainAxisAlignment: MainAxisAlignment.end, @@ -174,12 +176,15 @@ class TakeBuyOrderScreen extends ConsumerWidget { onPressed: () { // Possibly pass the LN address or sats from the text fields final satsText = _satsAmountController.text; - final lndAddress = _lndAddressController.text.trim(); // Convert satsText to int if needed final satsAmount = int.tryParse(satsText); - - orderDetailsNotifier.takeBuyOrder(realOrderId, satsAmount); - // Could also pass the LN address if your method expects it + if (orderType == OrderType.buy) { + orderDetailsNotifier.takeBuyOrder(realOrderId, satsAmount); + } else { + final lndAddress = _lndAddressController.text.trim(); + orderDetailsNotifier.takeSellOrder( + realOrderId, satsAmount, lndAddress); + } // Could also pass the LN address if your method expects it }, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.mostroGreen, diff --git a/lib/features/take_order/screens/take_sell_order_screen.dart b/lib/features/take_order/screens/take_sell_order_screen.dart deleted file mode 100644 index 1d08b3a5..00000000 --- a/lib/features/take_order/screens/take_sell_order_screen.dart +++ /dev/null @@ -1,276 +0,0 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/app/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/data/models/order.dart'; -import 'package:mostro_mobile/features/take_order/providers/order_notifier_providers.dart'; -import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; -import 'package:mostro_mobile/features/take_order/widgets/buyer_info.dart'; -import 'package:mostro_mobile/features/take_order/widgets/completion_message.dart'; -import 'package:mostro_mobile/features/take_order/widgets/seller_info.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/shared/providers/exchange_service_provider.dart'; -import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; -import 'package:mostro_mobile/shared/widgets/custom_card.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart' as actions; - -class TakeSellOrderScreen extends ConsumerWidget { - final String orderId; - final TextEditingController _satsAmountController = TextEditingController(); - final TextEditingController _lndAdrress = TextEditingController(); - - TakeSellOrderScreen({super.key, required this.orderId}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final orderDetailsState = ref.watch(takeSellOrderNotifierProvider(orderId)); - switch (orderDetailsState.action) { - case actions.Action.takeSell: - return _buildContent(context, ref); - case actions.Action.addInvoice: - return _buildLightningInvoiceInput(context, ref); - case actions.Action.waitingSellerToPay: - return _buildCompletionMessage(); - default: - return const Center(child: Text('Order not found')); - } - } - - Widget _buildCompletionMessage() { - final message = 'Order has been completed successfully!'; - return CompletionMessage(message: message); - } - - Widget _buildContent(BuildContext context, WidgetRef ref) { - final orderAsyncValue = ref.read(eventProvider(orderId)); - return Scaffold( - backgroundColor: AppTheme.dark1, - appBar: OrderAppBar(title: 'SELL BITCOIN'), - body: orderAsyncValue.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('Error: $error')), - data: (initialOrder) { - // If order is null => show "Order not found" - if (initialOrder == null) { - return const Center(child: Text('Order not found')); - } - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - CustomCard( - padding: EdgeInsets.all(16), - child: SellerInfo(order: initialOrder)), - const SizedBox(height: 16), - _buildSellerAmount(ref, initialOrder), - const SizedBox(height: 16), - ExchangeRateWidget(currency: initialOrder.currency!), - const SizedBox(height: 16), - CustomCard( - padding: const EdgeInsets.all(16), - child: BuyerInfo(order: initialOrder)), - const SizedBox(height: 16), - _buildBuyerAmount(initialOrder.amount!), - const SizedBox(height: 16), - _buildLnAddress(), - const SizedBox(height: 16), - _buildActionButtons(context, ref), - ], - ), - ), - ); - }, - ), - ); - } - - Widget _buildSellerAmount(WidgetRef ref, NostrEvent initialOrder) { - final exchangeRateAsyncValue = - ref.watch(exchangeRateProvider(initialOrder.currency!)); - return exchangeRateAsyncValue.when( - loading: () => const CircularProgressIndicator(), - error: (error, _) => Text('Error: $error'), - data: (exchangeRate) { - return CustomCard( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${initialOrder.fiatAmount} ${initialOrder.currency} (${initialOrder.premium}%)', - style: const TextStyle( - color: AppTheme.cream1, - fontSize: 18, - fontWeight: FontWeight.bold)), - Text('${initialOrder.amount} sats', - style: const TextStyle(color: AppTheme.grey2)), - ], - ) - ], - ), - ); - }, - ); - } - - Widget _buildLightningInvoiceInput(BuildContext context, WidgetRef ref) { - final orderDetailsNotifier = - ref.read(takeSellOrderNotifierProvider(orderId).notifier); - final state = ref.watch(takeSellOrderNotifierProvider(orderId)); - final order = (state.payload is Order) ? state.payload as Order : null; - - final TextEditingController invoiceController = TextEditingController(); - final val = order?.amount; - return Scaffold( - backgroundColor: AppTheme.dark1, - appBar: OrderAppBar(title: 'Add a Lightning Invoice'), - body: CustomCard( - padding: const EdgeInsets.all(16), - child: Material( - color: Colors.transparent, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Please enter a Lightning Invoice for $val sats:", - style: TextStyle(color: AppTheme.cream1, fontSize: 16), - ), - const SizedBox(height: 8), - TextFormField( - controller: invoiceController, - style: const TextStyle(color: AppTheme.cream1), - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - labelText: "Lightning Invoice", - labelStyle: const TextStyle(color: AppTheme.grey2), - hintText: "Enter invoice here", - hintStyle: const TextStyle(color: AppTheme.grey2), - filled: true, - fillColor: AppTheme.dark1, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () { - context.go('/'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - ), - child: const Text('CANCEL'), - ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton( - onPressed: () { - final invoice = invoiceController.text.trim(); - if (invoice.isNotEmpty) { - orderDetailsNotifier.sendInvoice( - orderId, invoice, val); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('SUBMIT'), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _buildBuyerAmount(String amount) { - return CustomCard( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CurrencyTextField(controller: _satsAmountController, label: 'Sats'), - const SizedBox(height: 8), - Text('\$ $amount', style: const TextStyle(color: AppTheme.grey2)), - const SizedBox(height: 24), - ], - ), - ); - } - - Widget _buildLnAddress() { - return CustomCard( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: AppTheme.dark1, - borderRadius: BorderRadius.circular(8), - ), - child: TextFormField( - controller: _lndAdrress, - style: const TextStyle(color: AppTheme.cream1), - decoration: InputDecoration( - border: InputBorder.none, - labelText: "Enter a Lightning Address", - labelStyle: const TextStyle(color: AppTheme.grey2), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a value'; - } - return null; - }, - ), - ), - ], - ), - ); - } - - Widget _buildActionButtons(BuildContext context, WidgetRef ref) { - final orderDetailsNotifier = - ref.read(takeSellOrderNotifierProvider(orderId).notifier); - - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ElevatedButton( - onPressed: () { - context.go('/'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - ), - child: const Text('CANCEL', style: TextStyle(color: AppTheme.red2)), - ), - const SizedBox(width: 16), - ElevatedButton( - onPressed: () => - orderDetailsNotifier.takeSellOrder(orderId, null, null), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('CONTINUE'), - ), - ], - ); - } -} From 330a05f86ddfe036466de735169f296c10b726a8 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 8 Feb 2025 12:32:08 -0800 Subject: [PATCH 033/149] removed bloc dependancy --- lib/app/app.dart | 3 + lib/app/app_routes.dart | 5 - lib/app/config.dart | 4 +- lib/data/repositories/session_manager.dart | 10 +- .../add_order/screens/add_order_screen.dart | 4 +- .../add_order/widgets/buy_form_widget.dart | 4 +- .../add_order/widgets/sell_form_widget.dart | 4 +- lib/features/auth/screens/login_screen.dart | 2 +- .../auth/screens/register_screen.dart | 2 +- lib/features/auth/screens/welcome_screen.dart | 2 +- .../chat/screens/chat_detail_screen.dart | 2 +- .../chat/screens/chat_list_screen.dart | 5 +- lib/features/home/screens/home_screen.dart | 4 +- .../foreground_service_controller.dart | 6 +- .../notification_controller.dart | 282 ++++++++++++ .../notifications/notification_page.dart | 218 ++++++++++ .../notfiers/abstract_order_notifier.dart | 12 +- .../order/notfiers/order_notifier.dart | 4 +- .../screens/payment_confirmation_screen.dart | 126 ++++++ .../order/screens/payment_qr_screen.dart | 86 ++++ .../order_book/screens/order_book_screen.dart | 4 +- .../screens/pay_lightning_invoice_screen.dart | 2 +- .../take_order/screens/take_order_screen.dart | 12 +- lib/main.dart | 4 + .../bloc/payment_confirmation_bloc.dart | 18 - .../bloc/payment_confirmation_event.dart | 12 - .../bloc/payment_confirmation_state.dart | 30 -- .../screens/payment_confirmation_screen.dart | 115 ----- .../payment_qr/bloc/payment_qr_bloc.dart | 18 - .../payment_qr/bloc/payment_qr_event.dart | 12 - .../payment_qr/bloc/payment_qr_state.dart | 31 -- .../payment_qr/screens/payment_qr_screen.dart | 77 ---- .../profile/bloc/profile_bloc.dart | 30 -- .../profile/bloc/profile_event.dart | 10 - .../profile/bloc/profile_state.dart | 43 -- .../profile/screens/profile_screen.dart | 123 ------ lib/services/mostro_service.dart | 11 +- .../widgets/bottom_nav_bar.dart | 0 .../widgets/currency_dropdown.dart | 0 .../widgets/currency_text_field.dart | 0 .../widgets/custom_app_bar.dart | 0 .../widgets/custom_button.dart | 0 .../widgets/exchange_rate_widget.dart | 0 linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 48 +-- pubspec.yaml | 4 +- .../mostro_service_helper_functions.dart | 31 ++ test/services/mostro_service_test.dart | 383 +++++++++++++++++ test/services/mostro_service_test.mocks.dart | 400 ++++++++++++++++++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 53 files changed, 1617 insertions(+), 597 deletions(-) rename lib/{app => features/notifications}/foreground_service_controller.dart (71%) create mode 100644 lib/features/notifications/notification_controller.dart create mode 100644 lib/features/notifications/notification_page.dart create mode 100644 lib/features/order/screens/payment_confirmation_screen.dart create mode 100644 lib/features/order/screens/payment_qr_screen.dart delete mode 100644 lib/presentation/payment_confirmation/bloc/payment_confirmation_bloc.dart delete mode 100644 lib/presentation/payment_confirmation/bloc/payment_confirmation_event.dart delete mode 100644 lib/presentation/payment_confirmation/bloc/payment_confirmation_state.dart delete mode 100644 lib/presentation/payment_confirmation/screens/payment_confirmation_screen.dart delete mode 100644 lib/presentation/payment_qr/bloc/payment_qr_bloc.dart delete mode 100644 lib/presentation/payment_qr/bloc/payment_qr_event.dart delete mode 100644 lib/presentation/payment_qr/bloc/payment_qr_state.dart delete mode 100644 lib/presentation/payment_qr/screens/payment_qr_screen.dart delete mode 100644 lib/presentation/profile/bloc/profile_bloc.dart delete mode 100644 lib/presentation/profile/bloc/profile_event.dart delete mode 100644 lib/presentation/profile/bloc/profile_state.dart delete mode 100644 lib/presentation/profile/screens/profile_screen.dart rename lib/{presentation => shared}/widgets/bottom_nav_bar.dart (100%) rename lib/{presentation => shared}/widgets/currency_dropdown.dart (100%) rename lib/{presentation => shared}/widgets/currency_text_field.dart (100%) rename lib/{presentation => shared}/widgets/custom_app_bar.dart (100%) rename lib/{presentation => shared}/widgets/custom_button.dart (100%) rename lib/{presentation => shared}/widgets/exchange_rate_widget.dart (100%) create mode 100644 test/services/mostro_service_helper_functions.dart create mode 100644 test/services/mostro_service_test.dart create mode 100644 test/services/mostro_service_test.mocks.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index b9936f0c..9d7e4385 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -12,6 +12,9 @@ import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; class MostroApp extends ConsumerWidget { const MostroApp({super.key}); + static final GlobalKey navigatorKey = + GlobalKey(); + @override Widget build(BuildContext context, WidgetRef ref) { final initAsyncValue = ref.watch(appInitializerProvider); diff --git a/lib/app/app_routes.dart b/lib/app/app_routes.dart index 4e799727..cfbb0a23 100644 --- a/lib/app/app_routes.dart +++ b/lib/app/app_routes.dart @@ -10,7 +10,6 @@ import 'package:mostro_mobile/features/order_book/screens/order_book_screen.dart import 'package:mostro_mobile/features/take_order/screens/add_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/pay_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/take_order_screen.dart'; -import 'package:mostro_mobile/presentation/profile/screens/profile_screen.dart'; import 'package:mostro_mobile/features/auth/screens/register_screen.dart'; import 'package:mostro_mobile/shared/widgets/navigation_listener_widget.dart'; import 'package:mostro_mobile/shared/widgets/notification_listener_widget.dart'; @@ -43,10 +42,6 @@ final goRouter = GoRouter( path: '/chat_list', builder: (context, state) => const ChatListScreen(), ), - GoRoute( - path: '/profile', - builder: (context, state) => const ProfileScreen(), - ), GoRoute( path: '/register', builder: (context, state) => const RegisterScreen(), diff --git a/lib/app/config.dart b/lib/app/config.dart index 6da66bea..11443d4e 100644 --- a/lib/app/config.dart +++ b/lib/app/config.dart @@ -3,9 +3,9 @@ import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ - //'ws://127.0.0.1:7000', + 'ws://127.0.0.1:7000', //'ws://10.0.2.2:7000', // mobile emulator - 'ws://192.168.1.148:7000', + //'ws://192.168.1.148:7000', //'wss://relay.mostro.network', ]; diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index f7a47c23..072b0080 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; import 'package:mostro_mobile/features/key_manager/key_manager.dart'; import 'package:mostro_mobile/data/models/session.dart'; @@ -9,6 +10,7 @@ class SessionManager { final KeyManager _keyManager; final FlutterSecureStorage _secureStorage; final Map _sessions = {}; + final _logger = Logger(); Timer? _cleanupTimer; final int sessionExpirationHours = 48; @@ -28,7 +30,7 @@ class SessionManager { final session = await _decodeSession(entry.value); _sessions[session.keyIndex] = session; } catch (e) { - print('Error decoding session for key ${entry.key}: $e'); + _logger.e('Error decoding session for key ${entry.key}: $e'); // Decide if you want to remove the corrupted entry } } @@ -79,7 +81,7 @@ class SessionManager { _sessions[keyIndex] = session; return session; } catch (e) { - print('Error decoding session index $keyIndex: $e'); + _logger.e('Error decoding session index $keyIndex: $e'); } } return null; @@ -122,14 +124,14 @@ class SessionManager { processedCount++; } } catch (e) { - print('Error processing session ${entry.key}: $e'); + _logger.e('Error processing session ${entry.key}: $e'); // Possibly remove corrupted entry await _secureStorage.delete(key: entry.key); processedCount++; } } } catch (e) { - print('Error during session cleanup: $e'); + _logger.e('Error during session cleanup: $e'); } } diff --git a/lib/features/add_order/screens/add_order_screen.dart b/lib/features/add_order/screens/add_order_screen.dart index 8adc21f1..050ae030 100644 --- a/lib/features/add_order/screens/add_order_screen.dart +++ b/lib/features/add_order/screens/add_order_screen.dart @@ -9,8 +9,8 @@ import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/add_order/providers/add_order_notifier_provider.dart'; import 'package:mostro_mobile/features/add_order/widgets/fixed_switch_widget.dart'; -import 'package:mostro_mobile/presentation/widgets/currency_dropdown.dart'; -import 'package:mostro_mobile/presentation/widgets/currency_text_field.dart'; +import 'package:mostro_mobile/shared/widgets/currency_dropdown.dart'; +import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:uuid/uuid.dart'; diff --git a/lib/features/add_order/widgets/buy_form_widget.dart b/lib/features/add_order/widgets/buy_form_widget.dart index c6687483..d57cca24 100644 --- a/lib/features/add_order/widgets/buy_form_widget.dart +++ b/lib/features/add_order/widgets/buy_form_widget.dart @@ -4,8 +4,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/presentation/widgets/currency_dropdown.dart'; -import 'package:mostro_mobile/presentation/widgets/currency_text_field.dart'; +import 'package:mostro_mobile/shared/widgets/currency_dropdown.dart'; +import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; class BuyFormWidget extends HookConsumerWidget { const BuyFormWidget({super.key}); diff --git a/lib/features/add_order/widgets/sell_form_widget.dart b/lib/features/add_order/widgets/sell_form_widget.dart index aa14d222..1bbbecea 100644 --- a/lib/features/add_order/widgets/sell_form_widget.dart +++ b/lib/features/add_order/widgets/sell_form_widget.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/presentation/widgets/currency_dropdown.dart'; -import 'package:mostro_mobile/presentation/widgets/currency_text_field.dart'; +import 'package:mostro_mobile/shared/widgets/currency_dropdown.dart'; +import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; class SellFormWidget extends HookConsumerWidget { const SellFormWidget({super.key}); diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart index 1bf32176..e5322ce9 100644 --- a/lib/features/auth/screens/login_screen.dart +++ b/lib/features/auth/screens/login_screen.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mostro_mobile/features/auth/notifiers/auth_state.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; -import 'package:mostro_mobile/presentation/widgets/custom_button.dart'; +import 'package:mostro_mobile/shared/widgets/custom_button.dart'; class LoginScreen extends HookConsumerWidget { const LoginScreen({super.key}); diff --git a/lib/features/auth/screens/register_screen.dart b/lib/features/auth/screens/register_screen.dart index 0ac1bbbf..e9ed0ecd 100644 --- a/lib/features/auth/screens/register_screen.dart +++ b/lib/features/auth/screens/register_screen.dart @@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mostro_mobile/features/auth/notifiers/auth_state.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; -import 'package:mostro_mobile/presentation/widgets/custom_button.dart'; +import 'package:mostro_mobile/shared/widgets/custom_button.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class RegisterScreen extends HookConsumerWidget { diff --git a/lib/features/auth/screens/welcome_screen.dart b/lib/features/auth/screens/welcome_screen.dart index f135d64d..94dd66f2 100644 --- a/lib/features/auth/screens/welcome_screen.dart +++ b/lib/features/auth/screens/welcome_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/presentation/widgets/custom_button.dart'; +import 'package:mostro_mobile/shared/widgets/custom_button.dart'; class WelcomeScreen extends StatelessWidget { const WelcomeScreen({super.key}); diff --git a/lib/features/chat/screens/chat_detail_screen.dart b/lib/features/chat/screens/chat_detail_screen.dart index 56fcf37e..59f69099 100644 --- a/lib/features/chat/screens/chat_detail_screen.dart +++ b/lib/features/chat/screens/chat_detail_screen.dart @@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/features/chat/notifiers/chat_detail_state.dart'; import 'package:mostro_mobile/features/chat/providers/chat_list_provider.dart'; -import 'package:mostro_mobile/presentation/widgets/bottom_nav_bar.dart'; +import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; class ChatDetailScreen extends ConsumerStatefulWidget { final String chatId; diff --git a/lib/features/chat/screens/chat_list_screen.dart b/lib/features/chat/screens/chat_list_screen.dart index 39c5a7d5..76c4c6ae 100644 --- a/lib/features/chat/screens/chat_list_screen.dart +++ b/lib/features/chat/screens/chat_list_screen.dart @@ -2,11 +2,10 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:mostro_mobile/data/models/chat_model.dart'; import 'package:mostro_mobile/features/chat/notifiers/chat_list_state.dart'; import 'package:mostro_mobile/features/chat/providers/chat_list_provider.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/shared/widgets/bottom_nav_bar.dart'; +import 'package:mostro_mobile/shared/widgets/custom_app_bar.dart'; class ChatListScreen extends ConsumerWidget { const ChatListScreen({super.key}); diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 3291acc4..647cbff7 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -6,8 +6,8 @@ import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/features/home/notifiers/home_notifier.dart'; import 'package:mostro_mobile/features/home/providers/home_notifer_provider.dart'; import 'package:mostro_mobile/features/home/notifiers/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/shared/widgets/bottom_nav_bar.dart'; +import 'package:mostro_mobile/shared/widgets/custom_app_bar.dart'; import 'package:mostro_mobile/features/home/widgets/order_filter.dart'; import 'package:mostro_mobile/features/home/widgets/order_list.dart'; diff --git a/lib/app/foreground_service_controller.dart b/lib/features/notifications/foreground_service_controller.dart similarity index 71% rename from lib/app/foreground_service_controller.dart rename to lib/features/notifications/foreground_service_controller.dart index 0b4493e3..8827163b 100644 --- a/lib/app/foreground_service_controller.dart +++ b/lib/features/notifications/foreground_service_controller.dart @@ -1,13 +1,15 @@ import 'package:flutter/services.dart'; +import 'package:logger/logger.dart'; class ForegroundServiceController { static const _channel = MethodChannel('com.example.myapp/foreground_service'); + static final _logger = Logger(); static Future startService() async { try { await _channel.invokeMethod('startService'); } on PlatformException catch (e) { - print("Failed to start service: ${e.message}"); + _logger.e("Failed to start service: ${e.message}"); } } @@ -15,7 +17,7 @@ class ForegroundServiceController { try { await _channel.invokeMethod('stopService'); } on PlatformException catch (e) { - print("Failed to stop service: ${e.message}"); + _logger.e("Failed to stop service: ${e.message}"); } } } diff --git a/lib/features/notifications/notification_controller.dart b/lib/features/notifications/notification_controller.dart new file mode 100644 index 00000000..b0ac0e85 --- /dev/null +++ b/lib/features/notifications/notification_controller.dart @@ -0,0 +1,282 @@ +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:awesome_notifications/awesome_notifications.dart'; +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/app/app.dart'; +import 'package:http/http.dart' as http; + +class NotificationController { + static ReceivedAction? initialAction; + + /// ********************************************* + /// INITIALIZATIONS + /// ********************************************* + /// + static Future initializeLocalNotifications() async { + await AwesomeNotifications().initialize( + null, //'resource://drawable/res_app_icon',// + [ + NotificationChannel( + channelKey: 'alerts', + channelName: 'Alerts', + channelDescription: 'Notification tests as alerts', + playSound: true, + onlyAlertOnce: true, + groupAlertBehavior: GroupAlertBehavior.Children, + importance: NotificationImportance.High, + defaultPrivacy: NotificationPrivacy.Private, + defaultColor: Colors.deepPurple, + ledColor: Colors.deepPurple) + ], + debug: true); + + // Get initial notification action is optional + initialAction = await AwesomeNotifications() + .getInitialNotificationAction(removeFromActionEvents: false); + } + + static ReceivePort? receivePort; + static Future initializeIsolateReceivePort() async { + receivePort = ReceivePort('Notification action port in main isolate') + ..listen( + (silentData) => onActionReceivedImplementationMethod(silentData)); + + // This initialization only happens on main isolate + IsolateNameServer.registerPortWithName( + receivePort!.sendPort, 'notification_action_port'); + } + + /// ********************************************* + /// NOTIFICATION EVENTS LISTENER + /// ********************************************* + /// Notifications events are only delivered after call this method + static Future startListeningNotificationEvents() async { + AwesomeNotifications() + .setListeners(onActionReceivedMethod: onActionReceivedMethod); + } + + /// ********************************************* + /// NOTIFICATION EVENTS + /// ********************************************* + /// + @pragma('vm:entry-point') + static Future onActionReceivedMethod( + ReceivedAction receivedAction) async { + if (receivedAction.actionType == ActionType.SilentAction || + receivedAction.actionType == ActionType.SilentBackgroundAction) { + // For background actions, you must hold the execution until the end + print( + 'Message sent via notification input: "${receivedAction.buttonKeyInput}"'); + await executeLongTaskInBackground(); + } else { + // this process is only necessary when you need to redirect the user + // to a new page or use a valid context, since parallel isolates do not + // have valid context, so you need redirect the execution to main isolate + if (receivePort == null) { + print( + 'onActionReceivedMethod was called inside a parallel dart isolate.'); + SendPort? sendPort = + IsolateNameServer.lookupPortByName('notification_action_port'); + + if (sendPort != null) { + print('Redirecting the execution to main isolate process.'); + sendPort.send(receivedAction); + return; + } + } + + return onActionReceivedImplementationMethod(receivedAction); + } + } + + static Future onActionReceivedImplementationMethod( + ReceivedAction receivedAction) async { + MostroApp.navigatorKey.currentState?.pushNamedAndRemoveUntil( + '/notification-page', + (route) => + (route.settings.name != '/notification-page') || route.isFirst, + arguments: receivedAction); + } + + /// ********************************************* + /// REQUESTING NOTIFICATION PERMISSIONS + /// ********************************************* + /// + static Future displayNotificationRationale() async { + bool userAuthorized = false; + BuildContext context = MostroApp.navigatorKey.currentContext!; + await showDialog( + context: context, + builder: (BuildContext ctx) { + return AlertDialog( + title: Text('Get Notified!', + style: Theme.of(context).textTheme.titleLarge), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Image.asset( + 'assets/images/animated-bell.gif', + height: MediaQuery.of(context).size.height * 0.3, + fit: BoxFit.fitWidth, + ), + ), + ], + ), + const SizedBox(height: 20), + const Text( + 'Allow Awesome Notifications to send you beautiful notifications!'), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + }, + child: Text( + 'Deny', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: Colors.red), + )), + TextButton( + onPressed: () async { + userAuthorized = true; + Navigator.of(ctx).pop(); + }, + child: Text( + 'Allow', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: Colors.deepPurple), + )), + ], + ); + }); + return userAuthorized && + await AwesomeNotifications().requestPermissionToSendNotifications(); + } + + /// ********************************************* + /// BACKGROUND TASKS TEST + /// ********************************************* + static Future executeLongTaskInBackground() async { + print("starting long task"); + await Future.delayed(const Duration(seconds: 4)); + final url = Uri.parse("http://google.com"); + final re = await http.get(url); + print(re.body); + print("long task done"); + } + + /// ********************************************* + /// NOTIFICATION CREATION METHODS + /// ********************************************* + /// + static Future createNewNotification() async { + bool isAllowed = await AwesomeNotifications().isNotificationAllowed(); + if (!isAllowed) isAllowed = await displayNotificationRationale(); + if (!isAllowed) return; + + await AwesomeNotifications().createNotification( + content: NotificationContent( + id: -1, // -1 is replaced by a random number + channelKey: 'alerts', + title: 'Huston! The eagle has landed!', + body: + "A small step for a man, but a giant leap to Flutter's community!", + bigPicture: 'https://storage.googleapis.com/cms-storage-bucket/d406c736e7c4c57f5f61.png', + largeIcon: 'https://storage.googleapis.com/cms-storage-bucket/0dbfcc7a59cd1cf16282.png', + //'asset://assets/images/balloons-in-sky.jpg', + notificationLayout: NotificationLayout.BigPicture, + payload: {'notificationId': '1234567890'}), + actionButtons: [ + NotificationActionButton(key: 'REDIRECT', label: 'Redirect'), + NotificationActionButton( + key: 'REPLY', + label: 'Reply Message', + requireInputText: true, + actionType: ActionType.SilentAction), + NotificationActionButton( + key: 'DISMISS', + label: 'Dismiss', + actionType: ActionType.DismissAction, + isDangerousOption: true) + ]); + } + + static Future scheduleNewNotification() async { + bool isAllowed = await AwesomeNotifications().isNotificationAllowed(); + if (!isAllowed) isAllowed = await displayNotificationRationale(); + if (!isAllowed) return; + + await myNotifyScheduleInHours( + title: 'test', + msg: 'test message', + heroThumbUrl: + 'https://storage.googleapis.com/cms-storage-bucket/d406c736e7c4c57f5f61.png', + hoursFromNow: 5, + username: 'test user', + repeatNotif: false); + } + + static Future resetBadgeCounter() async { + await AwesomeNotifications().resetGlobalBadge(); + } + + static Future cancelNotifications() async { + await AwesomeNotifications().cancelAll(); + } +} + + +Future myNotifyScheduleInHours({ + required int hoursFromNow, + required String heroThumbUrl, + required String username, + required String title, + required String msg, + bool repeatNotif = false, +}) async { + var nowDate = DateTime.now().add(Duration(hours: hoursFromNow, seconds: 5)); + await AwesomeNotifications().createNotification( + schedule: NotificationCalendar( + //weekday: nowDate.day, + hour: nowDate.hour, + minute: 0, + second: nowDate.second, + repeats: repeatNotif, + //allowWhileIdle: true, + ), + // schedule: NotificationCalendar.fromDate( + // date: DateTime.now().add(const Duration(seconds: 10))), + content: NotificationContent( + id: -1, + channelKey: 'basic_channel', + title: '${Emojis.food_bowl_with_spoon} $title', + body: '$username, $msg', + bigPicture: heroThumbUrl, + notificationLayout: NotificationLayout.BigPicture, + //actionType : ActionType.DismissAction, + color: Colors.black, + backgroundColor: Colors.black, + // customSound: 'resource://raw/notif', + payload: {'actPag': 'myAct', 'actType': 'food', 'username': username}, + ), + actionButtons: [ + NotificationActionButton( + key: 'NOW', + label: 'btnAct1', + ), + NotificationActionButton( + key: 'LATER', + label: 'btnAct2', + ), + ], + ); +} diff --git a/lib/features/notifications/notification_page.dart b/lib/features/notifications/notification_page.dart new file mode 100644 index 00000000..500d6f7a --- /dev/null +++ b/lib/features/notifications/notification_page.dart @@ -0,0 +1,218 @@ +import 'package:awesome_notifications/awesome_notifications.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:palette_generator/palette_generator.dart'; + +/// ********************************************* +/// NOTIFICATION PAGE +/// ********************************************* +class NotificationPage extends StatefulWidget { + const NotificationPage({ + super.key, + required this.receivedAction, + }); + + final ReceivedAction receivedAction; + + @override + NotificationPageState createState() => NotificationPageState(); +} + +class NotificationPageState extends State { + bool get hasTitle => widget.receivedAction.title?.isNotEmpty ?? false; + bool get hasBody => widget.receivedAction.body?.isNotEmpty ?? false; + bool get hasLargeIcon => widget.receivedAction.largeIconImage != null; + bool get hasBigPicture => widget.receivedAction.bigPictureImage != null; + + double bigPictureSize = 0.0; + double largeIconSize = 0.0; + bool isTotallyCollapsed = false; + bool bigPictureIsPredominantlyWhite = true; + + ScrollController scrollController = ScrollController(); + + Future isImagePredominantlyWhite(ImageProvider imageProvider) async { + final paletteGenerator = + await PaletteGenerator.fromImageProvider(imageProvider); + final dominantColor = + paletteGenerator.dominantColor?.color ?? Colors.transparent; + return dominantColor.computeLuminance() > 0.5; + } + + @override + void initState() { + super.initState(); + scrollController.addListener(_scrollListener); + + if (hasBigPicture) { + isImagePredominantlyWhite(widget.receivedAction.bigPictureImage!) + .then((isPredominantlyWhite) => setState(() { + bigPictureIsPredominantlyWhite = isPredominantlyWhite; + })); + } + } + + void _scrollListener() { + bool pastScrollLimit = scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 240; + + if (!hasBigPicture) { + isTotallyCollapsed = true; + return; + } + + if (isTotallyCollapsed) { + if (!pastScrollLimit) { + setState(() { + isTotallyCollapsed = false; + }); + } + } else { + if (pastScrollLimit) { + setState(() { + isTotallyCollapsed = true; + }); + } + } + } + + @override + Widget build(BuildContext context) { + bigPictureSize = MediaQuery.of(context).size.height * .4; + largeIconSize = + MediaQuery.of(context).size.height * (hasBigPicture ? .16 : .2); + + if (!hasBigPicture) { + isTotallyCollapsed = true; + } + + return Scaffold( + body: CustomScrollView( + controller: scrollController, + physics: const BouncingScrollPhysics(), + slivers: [ + SliverAppBar( + elevation: 0, + centerTitle: true, + leading: IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon( + Icons.arrow_back_ios_rounded, + color: isTotallyCollapsed || bigPictureIsPredominantlyWhite + ? Colors.black + : Colors.white, + ), + ), + systemOverlayStyle: + isTotallyCollapsed || bigPictureIsPredominantlyWhite + ? SystemUiOverlayStyle.dark + : SystemUiOverlayStyle.light, + expandedHeight: hasBigPicture + ? bigPictureSize + (hasLargeIcon ? 40 : 0) + : (hasLargeIcon) + ? largeIconSize + 10 + : MediaQuery.of(context).padding.top + 28, + backgroundColor: Colors.transparent, + stretch: true, + flexibleSpace: FlexibleSpaceBar( + stretchModes: const [StretchMode.zoomBackground], + centerTitle: true, + expandedTitleScale: 1, + collapseMode: CollapseMode.pin, + title: (!hasLargeIcon) + ? null + : Stack(children: [ + Positioned( + bottom: 0, + left: 16, + right: 16, + child: Row( + mainAxisAlignment: hasBigPicture + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + SizedBox( + height: largeIconSize, + width: largeIconSize, + child: ClipRRect( + borderRadius: BorderRadius.all( + Radius.circular(largeIconSize)), + child: FadeInImage( + placeholder: const NetworkImage( + 'https://cdn.syncfusion.com/content/images/common/placeholder.gif'), + image: widget.receivedAction.largeIconImage!, + fit: BoxFit.cover, + ), + ), + ), + ], + ), + ), + ]), + background: hasBigPicture + ? Padding( + padding: EdgeInsets.only(bottom: hasLargeIcon ? 60 : 20), + child: FadeInImage( + placeholder: const NetworkImage( + 'https://cdn.syncfusion.com/content/images/common/placeholder.gif'), + height: bigPictureSize, + width: MediaQuery.of(context).size.width, + image: widget.receivedAction.bigPictureImage!, + fit: BoxFit.cover, + ), + ) + : null, + ), + ), + SliverList( + delegate: SliverChildListDelegate( + [ + Padding( + padding: + const EdgeInsets.only(bottom: 20.0, left: 20, right: 20), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan(children: [ + if (hasTitle) + TextSpan( + text: widget.receivedAction.title!, + style: Theme.of(context).textTheme.titleLarge, + ), + if (hasBody) + WidgetSpan( + child: Padding( + padding: EdgeInsets.only( + top: hasTitle ? 16.0 : 0.0, + ), + child: SizedBox( + width: MediaQuery.of(context).size.width, + child: Text( + widget.receivedAction.bodyWithoutHtml ?? + '', + style: Theme.of(context) + .textTheme + .bodyMedium)), + ), + ), + ]), + ), + ], + ), + ), + Container( + color: Colors.black12, + padding: const EdgeInsets.all(20), + width: MediaQuery.of(context).size.width, + child: Text(widget.receivedAction.toString()), + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index f27c6a86..5553bb71 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; @@ -12,6 +13,7 @@ class AbstractOrderNotifier extends StateNotifier { final Ref ref; final String orderId; StreamSubscription? _orderSubscription; + final logger = Logger(); AbstractOrderNotifier( this.orderRepository, @@ -32,7 +34,7 @@ class AbstractOrderNotifier extends StateNotifier { } void handleError(Object err) { - //ref.read(notificationProvider.notifier).showInformation(err.toString()); + logger.e(err); } void handleOrderUpdate() { @@ -47,13 +49,19 @@ class AbstractOrderNotifier extends StateNotifier { navProvider.go('/pay_invoice/${state.id!}'); break; case Action.outOfRangeSatsAmount: - case Action.outOfRangeFiatAmount: final order = state.getPayload(); notifProvider.showInformation(state.action, values: { 'min_order_amount': order?.minAmount, 'max_order_amount': order?.maxAmount }); break; + case Action.outOfRangeFiatAmount: + final order = state.getPayload(); + notifProvider.showInformation(state.action, values: { + 'min_amount': order?.minAmount, + 'max_amount': order?.maxAmount + }); + break; case Action.waitingSellerToPay: notifProvider.showInformation(state.action, values: {'id': state.id}); break; diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 3f552ad1..0f4e2fb2 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -7,9 +7,9 @@ class OrderNotifier extends AbstractOrderNotifier { } Future _reSubscribe() async { - final existingMessage = orderRepository.getOrderById(orderId); + final existingMessage = await orderRepository.getOrderById(orderId); if (existingMessage == null) { - print('Order $orderId not found in repository; subscription aborted.'); + logger.e('Order $orderId not found in repository; subscription aborted.'); return; } final stream = orderRepository.resubscribeOrder(orderId); diff --git a/lib/features/order/screens/payment_confirmation_screen.dart b/lib/features/order/screens/payment_confirmation_screen.dart new file mode 100644 index 00000000..e631c441 --- /dev/null +++ b/lib/features/order/screens/payment_confirmation_screen.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/data/models/cant_do.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as action; + +class PaymentConfirmationScreen extends ConsumerWidget { + final String orderId; + + const PaymentConfirmationScreen({super.key, required this.orderId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Watch the notifier’s state + final state = ref.watch(orderNotifierProvider(orderId)); + + return Scaffold( + backgroundColor: const Color(0xFF1D212C), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: const Text( + 'PAYMENT', + style: TextStyle(color: Colors.white), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => context.go('/'), + ), + ), + body: _buildBody(context, ref, state), + bottomNavigationBar: const BottomNavBar(), + ); + } + + Widget _buildBody(BuildContext context, WidgetRef ref, MostroMessage state) { + switch (state.action) { + case action.Action.notFound: + return const Center(child: CircularProgressIndicator()); + + case action.Action.purchaseCompleted: + final satoshis = 0; + + return Center( + child: Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF303544), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFF8CC541), + ), + child: const Icon( + Icons.check, + size: 50, + color: Colors.white, + ), + ), + const SizedBox(height: 24), + Text( + '$satoshis', + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const Text( + 'received', + style: TextStyle( + color: Colors.white, + fontSize: 18, + ), + ), + const SizedBox(height: 24), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF8CC541), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + minimumSize: const Size(double.infinity, 50), + ), + onPressed: () { + // Call the notifier’s method + //ref.read(paymentConfirmationProvider.notifier).continueAfterConfirmation(); + // You can navigate or do further logic + // e.g. context.go('/next_screen'); + }, + child: const Text('CONTINUE'), + ), + ], + ), + ), + ); + + case action.Action.cantDo: + final error = state.getPayload()?.cantDo; + return Center( + child: Text( + 'Error: $error', + style: const TextStyle(color: Colors.white), + ), + ); + default: + return Center( + child: Text( + 'Unkown Action: ${state.action}', + style: const TextStyle(color: Colors.white), + ), + ); + } + } +} diff --git a/lib/features/order/screens/payment_qr_screen.dart b/lib/features/order/screens/payment_qr_screen.dart new file mode 100644 index 00000000..b51c8d36 --- /dev/null +++ b/lib/features/order/screens/payment_qr_screen.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as action; + + +class PaymentQrScreen extends ConsumerWidget { + final String orderId; + + const PaymentQrScreen({super.key, required this.orderId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(orderNotifierProvider(orderId)); + + return Scaffold( + backgroundColor: const Color(0xFF1D212C), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: const Text('PAYMENT', style: TextStyle(color: Colors.white)), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => context.go('/'), + ), + ), + body: _buildBody(context, ref, state), + bottomNavigationBar: const BottomNavBar(), // from your code + ); + } + + Widget _buildBody(BuildContext context, WidgetRef ref, MostroMessage state) { + switch (state.action) { + case action.Action.notFound: + return const Center(child: CircularProgressIndicator()); + + case action.Action.payInvoice: + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Pay this invoice to continue the exchange', + style: TextStyle(color: Colors.white), + ), + const SizedBox(height: 20), + + // Possibly insert your QR code or something + // e.g. QrImage(...) + + const SizedBox(height: 20), + Text( + 'Expires in: ', + style: const TextStyle(color: Colors.white), + ), + const SizedBox(height: 20), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF8CC541), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + onPressed: () { + //ref.read(paymentQrProvider.notifier).openWallet(); + }, + child: const Text('OPEN WALLET'), + ), + const SizedBox(height: 20), + TextButton( + child: const Text('CANCEL', style: TextStyle(color: Colors.white)), + onPressed: () => context.go('/'), + ), + ], + ); + + default: + return Center( + child: Text('Unknown error: ${state.action}', + style: const TextStyle(color: Colors.white)), + ); + } + } +} diff --git a/lib/features/order_book/screens/order_book_screen.dart b/lib/features/order_book/screens/order_book_screen.dart index f68777f2..dc306ddf 100644 --- a/lib/features/order_book/screens/order_book_screen.dart +++ b/lib/features/order_book/screens/order_book_screen.dart @@ -5,8 +5,8 @@ import 'package:mostro_mobile/app/app_theme.dart'; import 'package:mostro_mobile/features/order_book/notifiers/order_book_state.dart'; import 'package:mostro_mobile/features/order_book/providers/order_book_notifier.dart'; import 'package:mostro_mobile/features/order_book/widgets/order_book_list.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/shared/widgets/bottom_nav_bar.dart'; +import 'package:mostro_mobile/shared/widgets/custom_app_bar.dart'; class OrderBookScreen extends ConsumerWidget { const OrderBookScreen({super.key}); diff --git a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart b/lib/features/take_order/screens/pay_lightning_invoice_screen.dart index 755e7aab..25ec8996 100644 --- a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/pay_lightning_invoice_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; // For Clipboard +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/app/app_theme.dart'; diff --git a/lib/features/take_order/screens/take_order_screen.dart b/lib/features/take_order/screens/take_order_screen.dart index a736e233..78a21282 100644 --- a/lib/features/take_order/screens/take_order_screen.dart +++ b/lib/features/take_order/screens/take_order_screen.dart @@ -8,8 +8,8 @@ import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/features/take_order/widgets/buyer_info.dart'; import 'package:mostro_mobile/features/take_order/widgets/seller_info.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/shared/widgets/currency_text_field.dart'; +import 'package:mostro_mobile/shared/widgets/exchange_rate_widget.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; @@ -66,7 +66,7 @@ class TakeOrderScreen extends ConsumerWidget { const SizedBox(height: 16), _buildLnAddress(), const SizedBox(height: 16), - _buildActionButtons(context, ref, order.id), + _buildActionButtons(context, ref, order.orderId), ], ), ); @@ -155,9 +155,9 @@ class TakeOrderScreen extends ConsumerWidget { // If there's no orderId, hide the button or handle it final realOrderId = orderId ?? ''; - final orderDetailsNotifier = orderType == OrderType.sell ? - ref.read(takeSellOrderNotifierProvider(realOrderId).notifier): - ref.read(takeBuyOrderNotifierProvider(realOrderId).notifier); + final orderDetailsNotifier = orderType == OrderType.sell + ? ref.read(takeSellOrderNotifierProvider(realOrderId).notifier) + : ref.read(takeBuyOrderNotifierProvider(realOrderId).notifier); return Row( mainAxisAlignment: MainAxisAlignment.end, diff --git a/lib/main.dart b/lib/main.dart index 3fce96d1..af8988ed 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mostro_mobile/app/app.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; +import 'package:mostro_mobile/features/notifications/notification_controller.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/storage_providers.dart'; @@ -12,6 +13,9 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + await NotificationController.initializeLocalNotifications(); + await NotificationController.initializeIsolateReceivePort(); + final nostrService = NostrService(); await nostrService.init(); final biometricsHelper = BiometricsHelper(); diff --git a/lib/presentation/payment_confirmation/bloc/payment_confirmation_bloc.dart b/lib/presentation/payment_confirmation/bloc/payment_confirmation_bloc.dart deleted file mode 100644 index 73f19124..00000000 --- a/lib/presentation/payment_confirmation/bloc/payment_confirmation_bloc.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'payment_confirmation_event.dart'; -import 'payment_confirmation_state.dart'; - -class PaymentConfirmationBloc extends Bloc { - PaymentConfirmationBloc() : super(PaymentConfirmationInitial()) { - on(_onLoadPaymentConfirmation); - on(_onContinueAfterConfirmation); - } - - void _onLoadPaymentConfirmation(LoadPaymentConfirmation event, Emitter emit) { - // TODO: Implementar lógica para cargar la confirmación de pago - } - - void _onContinueAfterConfirmation(ContinueAfterConfirmation event, Emitter emit) { - // TODO: Implementar lógica para continuar después de la confirmación - } -} \ No newline at end of file diff --git a/lib/presentation/payment_confirmation/bloc/payment_confirmation_event.dart b/lib/presentation/payment_confirmation/bloc/payment_confirmation_event.dart deleted file mode 100644 index c817c361..00000000 --- a/lib/presentation/payment_confirmation/bloc/payment_confirmation_event.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:equatable/equatable.dart'; - -abstract class PaymentConfirmationEvent extends Equatable { - const PaymentConfirmationEvent(); - - @override - List get props => []; -} - -class LoadPaymentConfirmation extends PaymentConfirmationEvent {} - -class ContinueAfterConfirmation extends PaymentConfirmationEvent {} \ No newline at end of file diff --git a/lib/presentation/payment_confirmation/bloc/payment_confirmation_state.dart b/lib/presentation/payment_confirmation/bloc/payment_confirmation_state.dart deleted file mode 100644 index a83601dc..00000000 --- a/lib/presentation/payment_confirmation/bloc/payment_confirmation_state.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:equatable/equatable.dart'; - -abstract class PaymentConfirmationState extends Equatable { - const PaymentConfirmationState(); - - @override - List get props => []; -} - -class PaymentConfirmationInitial extends PaymentConfirmationState {} - -class PaymentConfirmationLoading extends PaymentConfirmationState {} - -class PaymentConfirmationLoaded extends PaymentConfirmationState { - final int satoshisReceived; - - const PaymentConfirmationLoaded(this.satoshisReceived); - - @override - List get props => [satoshisReceived]; -} - -class PaymentConfirmationError extends PaymentConfirmationState { - final String error; - - const PaymentConfirmationError(this.error); - - @override - List get props => [error]; -} \ No newline at end of file diff --git a/lib/presentation/payment_confirmation/screens/payment_confirmation_screen.dart b/lib/presentation/payment_confirmation/screens/payment_confirmation_screen.dart deleted file mode 100644 index b755cbcc..00000000 --- a/lib/presentation/payment_confirmation/screens/payment_confirmation_screen.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import '../bloc/payment_confirmation_bloc.dart'; -import '../bloc/payment_confirmation_event.dart'; -import '../bloc/payment_confirmation_state.dart'; -import '../../widgets/bottom_nav_bar.dart'; - -class PaymentConfirmationScreen extends StatelessWidget { - const PaymentConfirmationScreen({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - PaymentConfirmationBloc()..add(LoadPaymentConfirmation()), - child: Scaffold( - backgroundColor: const Color(0xFF1D212C), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - title: const Text('PAYMENT', style: TextStyle(color: Colors.white)), - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => context.go('/'), - ), - ), - body: BlocBuilder( - builder: (context, state) { - if (state is PaymentConfirmationLoading) { - return const Center(child: CircularProgressIndicator()); - } else if (state is PaymentConfirmationLoaded) { - return Center( - child: Container( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: const Color(0xFF303544), - borderRadius: BorderRadius.circular(20), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 80, - height: 80, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Color(0xFF8CC541), - ), - child: const Icon( - Icons.check, - size: 50, - color: Colors.white, - ), - ), - const SizedBox(height: 24), - Text( - '${state.satoshisReceived} satoshis', - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const Text( - 'received', - style: TextStyle( - color: Colors.white, - fontSize: 18, - ), - ), - const SizedBox(height: 24), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8CC541), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - minimumSize: const Size(double.infinity, 50), - ), - onPressed: () { - context - .read() - .add(ContinueAfterConfirmation()); - // Aquí puedes navegar a la siguiente pantalla o realizar la acción necesaria - }, - child: const Text('CONTINUE'), - ), - ], - ), - ), - ); - } else if (state is PaymentConfirmationError) { - return Center( - child: Text( - 'Error: ${state.error}', - style: const TextStyle(color: Colors.white), - ), - ); - } else { - return const Center( - child: Text( - 'Unexpected state', - style: TextStyle(color: Colors.white), - ), - ); - } - }, - ), - bottomNavigationBar: const BottomNavBar(), - ), - ); - } -} diff --git a/lib/presentation/payment_qr/bloc/payment_qr_bloc.dart b/lib/presentation/payment_qr/bloc/payment_qr_bloc.dart deleted file mode 100644 index 5074e93d..00000000 --- a/lib/presentation/payment_qr/bloc/payment_qr_bloc.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'payment_qr_event.dart'; -import 'payment_qr_state.dart'; - -class PaymentQrBloc extends Bloc { - PaymentQrBloc() : super(PaymentQrInitial()) { - on(_onLoadPaymentQr); - on(_onOpenWallet); - } - - void _onLoadPaymentQr(LoadPaymentQr event, Emitter emit) { - // TODO: Implementar lógica para cargar el QR de pago - } - - void _onOpenWallet(OpenWallet event, Emitter emit) { - // TODO: Implementar lógica para abrir la wallet - } -} \ No newline at end of file diff --git a/lib/presentation/payment_qr/bloc/payment_qr_event.dart b/lib/presentation/payment_qr/bloc/payment_qr_event.dart deleted file mode 100644 index f3285c08..00000000 --- a/lib/presentation/payment_qr/bloc/payment_qr_event.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:equatable/equatable.dart'; - -abstract class PaymentQrEvent extends Equatable { - const PaymentQrEvent(); - - @override - List get props => []; -} - -class LoadPaymentQr extends PaymentQrEvent {} - -class OpenWallet extends PaymentQrEvent {} \ No newline at end of file diff --git a/lib/presentation/payment_qr/bloc/payment_qr_state.dart b/lib/presentation/payment_qr/bloc/payment_qr_state.dart deleted file mode 100644 index 61e891fa..00000000 --- a/lib/presentation/payment_qr/bloc/payment_qr_state.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:equatable/equatable.dart'; - -abstract class PaymentQrState extends Equatable { - const PaymentQrState(); - - @override - List get props => []; -} - -class PaymentQrInitial extends PaymentQrState {} - -class PaymentQrLoading extends PaymentQrState {} - -class PaymentQrLoaded extends PaymentQrState { - final String qrData; - final String expiresIn; - - const PaymentQrLoaded(this.qrData, this.expiresIn); - - @override - List get props => [qrData, expiresIn]; -} - -class PaymentQrError extends PaymentQrState { - final String error; - - const PaymentQrError(this.error); - - @override - List get props => [error]; -} \ No newline at end of file diff --git a/lib/presentation/payment_qr/screens/payment_qr_screen.dart b/lib/presentation/payment_qr/screens/payment_qr_screen.dart deleted file mode 100644 index 8ca11742..00000000 --- a/lib/presentation/payment_qr/screens/payment_qr_screen.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import '../bloc/payment_qr_bloc.dart'; -import '../bloc/payment_qr_event.dart'; -import '../bloc/payment_qr_state.dart'; -import '../../widgets/bottom_nav_bar.dart'; - -class PaymentQrScreen extends StatelessWidget { - const PaymentQrScreen({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => PaymentQrBloc()..add(LoadPaymentQr()), - child: Scaffold( - backgroundColor: const Color(0xFF1D212C), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - title: const Text('PAYMENT'), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/'), - ), - ), - body: BlocBuilder( - builder: (context, state) { - if (state is PaymentQrLoading) { - return const Center(child: CircularProgressIndicator()); - } else if (state is PaymentQrLoaded) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'Pay this invoice to continue the exchange', - style: TextStyle(color: Colors.white), - ), - const SizedBox(height: 20), - const SizedBox(height: 20), - Text( - 'Expires in: ${state.expiresIn}', - style: const TextStyle(color: Colors.white), - ), - const SizedBox(height: 20), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8CC541), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - onPressed: () { - context.read().add(OpenWallet()); - }, - child: const Text('OPEN WALLET'), - ), - const SizedBox(height: 20), - TextButton( - child: const Text('CANCEL', - style: TextStyle(color: Colors.white)), - onPressed: () => context.go('/'), - ), - ], - ); - } else if (state is PaymentQrError) { - return Center(child: Text(state.error)); - } else { - return const Center(child: Text('Something went wrong')); - } - }, - ), - bottomNavigationBar: const BottomNavBar(), - ), - ); - } -} diff --git a/lib/presentation/profile/bloc/profile_bloc.dart b/lib/presentation/profile/bloc/profile_bloc.dart deleted file mode 100644 index 20d3affd..00000000 --- a/lib/presentation/profile/bloc/profile_bloc.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'profile_event.dart'; -import 'profile_state.dart'; - -class ProfileBloc extends Bloc { - ProfileBloc() : super(const ProfileState()) { - on(_onLoadProfile); - } - - Future _onLoadProfile(LoadProfile event, Emitter emit) async { - emit(state.copyWith(status: ProfileStatus.loading)); - - try { - // Simulamos la carga del perfil con datos hardcodeados - await Future.delayed(const Duration(seconds: 1)); - emit(state.copyWith( - status: ProfileStatus.loaded, - username: 'SatoshiNakamoto', - pubkey: 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3xvmkv', - completedTrades: 42, - rating: 4.8, - )); - } catch (e) { - emit(state.copyWith( - status: ProfileStatus.error, - errorMessage: e.toString(), - )); - } - } -} \ No newline at end of file diff --git a/lib/presentation/profile/bloc/profile_event.dart b/lib/presentation/profile/bloc/profile_event.dart deleted file mode 100644 index 289b2fc1..00000000 --- a/lib/presentation/profile/bloc/profile_event.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:equatable/equatable.dart'; - -abstract class ProfileEvent extends Equatable { - const ProfileEvent(); - - @override - List get props => []; -} - -class LoadProfile extends ProfileEvent {} diff --git a/lib/presentation/profile/bloc/profile_state.dart b/lib/presentation/profile/bloc/profile_state.dart deleted file mode 100644 index 69672f51..00000000 --- a/lib/presentation/profile/bloc/profile_state.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:equatable/equatable.dart'; - -enum ProfileStatus { initial, loading, loaded, error } - -class ProfileState extends Equatable { - final ProfileStatus status; - final String username; - final String pubkey; - final int completedTrades; - final double rating; - final String errorMessage; - - const ProfileState({ - this.status = ProfileStatus.initial, - this.username = '', - this.pubkey = '', - this.completedTrades = 0, - this.rating = 0.0, - this.errorMessage = '', - }); - - ProfileState copyWith({ - ProfileStatus? status, - String? username, - String? pubkey, - int? completedTrades, - double? rating, - String? errorMessage, - }) { - return ProfileState( - status: status ?? this.status, - username: username ?? this.username, - pubkey: pubkey ?? this.pubkey, - completedTrades: completedTrades ?? this.completedTrades, - rating: rating ?? this.rating, - errorMessage: errorMessage ?? this.errorMessage, - ); - } - - @override - List get props => - [status, username, pubkey, completedTrades, rating, errorMessage]; -} diff --git a/lib/presentation/profile/screens/profile_screen.dart b/lib/presentation/profile/screens/profile_screen.dart deleted file mode 100644 index 3865c3b5..00000000 --- a/lib/presentation/profile/screens/profile_screen.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/presentation/profile/bloc/profile_bloc.dart'; -import 'package:mostro_mobile/presentation/profile/bloc/profile_event.dart'; -import 'package:mostro_mobile/presentation/profile/bloc/profile_state.dart'; -import 'package:mostro_mobile/presentation/widgets/bottom_nav_bar.dart'; -import 'package:mostro_mobile/presentation/widgets/custom_app_bar.dart'; - -class ProfileScreen extends StatelessWidget { - const ProfileScreen({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ProfileBloc()..add(LoadProfile()), - child: Scaffold( - backgroundColor: const Color(0xFF1D212C), - appBar: const CustomAppBar(), - body: Container( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), - decoration: BoxDecoration( - color: const Color(0xFF303544), - borderRadius: BorderRadius.circular(20), - ), - child: Column( - children: [ - Expanded( - child: BlocBuilder( - builder: (context, state) { - print('Current profile state: ${state.status}'); - switch (state.status) { - case ProfileStatus.initial: - return const Center(child: Text('Initializing...')); - case ProfileStatus.loading: - return const Center(child: CircularProgressIndicator()); - case ProfileStatus.loaded: - return _buildProfileContent(state); - case ProfileStatus.error: - return Center( - child: Text('Error: ${state.errorMessage}')); - } - }, - ), - ), - const BottomNavBar(), - ], - ), - ), - ), - ); - } - - Widget _buildProfileContent(ProfileState state) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: CircleAvatar( - radius: 50, - backgroundColor: Colors.grey, - child: Text( - state.username[0].toUpperCase(), - style: const TextStyle(fontSize: 40, color: Colors.white), - ), - ), - ), - const SizedBox(height: 16), - Center( - child: Text( - state.username, - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 8), - Center( - child: Text( - '${state.rating}/5 (${state.completedTrades} trades)', - style: const TextStyle(color: Color(0xFF8CC541)), - ), - ), - const SizedBox(height: 24), - const Text( - 'Public Key', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFF1D212C), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Expanded( - child: Text( - state.pubkey, - style: const TextStyle(color: Colors.white), - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - icon: - const HeroIcon(HeroIcons.clipboard, color: Colors.white), - onPressed: () { - // TODO: Implementar copia al portapapeles - }, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 9782b755..1c7c8948 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/app/config.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; @@ -12,6 +13,7 @@ import 'package:mostro_mobile/services/nostr_service.dart'; class MostroService { final NostrService _nostrService; final SessionManager _sessionManager; + final _logger = Logger(); MostroService(this._nostrService, this._sessionManager); @@ -46,6 +48,7 @@ class MostroService { : null; final content = newMessage(Action.takeSell, orderId, payload: order); + _logger.i(content); await sendMessage(orderId, Config.mostroPubKey, content); return session; } @@ -65,6 +68,7 @@ class MostroService { final session = await _sessionManager.newSession(orderId: orderId); final amt = amount != null ? {'amount': amount} : null; final content = newMessage(Action.takeBuy, orderId, payload: amt); + _logger.i(content); await sendMessage(orderId, Config.mostroPubKey, content); return session; } @@ -110,9 +114,9 @@ class MostroService { return { 'order': { 'version': Config.mostroVersion, - 'request_id': null, + 'request_id': orderId, 'trade_index': null, - 'id': orderId, + 'id': null, 'action': actionType.value, 'payload': payload, }, @@ -137,9 +141,10 @@ class MostroService { final event = await createNIP59Event(finalContent, receiverPubkey, session); await _nostrService.publishEvent(event); + _logger.i(finalContent); } catch (e) { // catch and throw and log and stuff - print(e); + _logger.e(e); } } diff --git a/lib/presentation/widgets/bottom_nav_bar.dart b/lib/shared/widgets/bottom_nav_bar.dart similarity index 100% rename from lib/presentation/widgets/bottom_nav_bar.dart rename to lib/shared/widgets/bottom_nav_bar.dart diff --git a/lib/presentation/widgets/currency_dropdown.dart b/lib/shared/widgets/currency_dropdown.dart similarity index 100% rename from lib/presentation/widgets/currency_dropdown.dart rename to lib/shared/widgets/currency_dropdown.dart diff --git a/lib/presentation/widgets/currency_text_field.dart b/lib/shared/widgets/currency_text_field.dart similarity index 100% rename from lib/presentation/widgets/currency_text_field.dart rename to lib/shared/widgets/currency_text_field.dart diff --git a/lib/presentation/widgets/custom_app_bar.dart b/lib/shared/widgets/custom_app_bar.dart similarity index 100% rename from lib/presentation/widgets/custom_app_bar.dart rename to lib/shared/widgets/custom_app_bar.dart diff --git a/lib/presentation/widgets/custom_button.dart b/lib/shared/widgets/custom_button.dart similarity index 100% rename from lib/presentation/widgets/custom_button.dart rename to lib/shared/widgets/custom_button.dart diff --git a/lib/presentation/widgets/exchange_rate_widget.dart b/lib/shared/widgets/exchange_rate_widget.dart similarity index 100% rename from lib/presentation/widgets/exchange_rate_widget.dart rename to lib/shared/widgets/exchange_rate_widget.dart diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f797..e6fa1ba4 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) awesome_notifications_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AwesomeNotificationsPlugin"); + awesome_notifications_plugin_register_with_registrar(awesome_notifications_registrar); 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..1e8c4c63 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + awesome_notifications flutter_secure_storage_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 00cea366..23aa2683 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import awesome_notifications import flutter_secure_storage_darwin import local_auth_darwin import path_provider_foundation @@ -12,6 +13,7 @@ import shared_preferences_foundation import sqflite_sqlcipher func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AwesomeNotificationsPlugin.register(with: registry.registrar(forPlugin: "AwesomeNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/pubspec.lock b/pubspec.lock index ac78dcec..74ebb742 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -46,6 +46,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + awesome_notifications: + dependency: "direct main" + description: + name: awesome_notifications + sha256: d051ffb694a53da216ff13d02c8ec645d75320048262f7e6b3c1d95a4f54c902 + url: "https://pub.dev" + source: hosted + version: "0.10.0" base58check: dependency: transitive description: @@ -102,14 +110,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.4" - bloc: - dependency: "direct main" - description: - name: bloc - sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" - url: "https://pub.dev" - source: hosted - version: "8.1.4" boolean_selector: dependency: transitive description: @@ -347,14 +347,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_bloc: - dependency: "direct main" - description: - name: flutter_bloc - sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a - url: "https://pub.dev" - source: hosted - version: "8.1.6" flutter_driver: dependency: transitive description: flutter @@ -753,14 +745,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.4" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" nip44: dependency: "direct main" description: @@ -786,6 +770,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + palette_generator: + dependency: "direct main" + description: + name: palette_generator + sha256: "0b20245c451f14a5ca0818ab7a377765162389f8e8f0db361cceabf0fed9d1ea" + url: "https://pub.dev" + source: hosted + version: "0.3.3+5" path: dependency: "direct main" description: @@ -906,14 +898,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" - provider: - dependency: transitive - description: - name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c - url: "https://pub.dev" - source: hosted - version: "6.1.2" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6d6ec916..155b3bda 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,8 +34,6 @@ dependencies: cupertino_icons: ^1.0.8 http: ^1.2.2 dart_nostr: ^8.2.0 - bloc: ^8.1.4 - flutter_bloc: ^8.1.6 qr_flutter: ^4.0.0 heroicons: ^0.10.0 crypto: ^3.0.5 @@ -70,6 +68,8 @@ dependencies: flutter_launcher_icons: ^0.14.2 bip32: ^2.0.0 path: ^1.9.0 + awesome_notifications: ^0.10.0 + palette_generator: ^0.3.3+5 dev_dependencies: flutter_test: diff --git a/test/services/mostro_service_helper_functions.dart b/test/services/mostro_service_helper_functions.dart new file mode 100644 index 00000000..45fa7ec3 --- /dev/null +++ b/test/services/mostro_service_helper_functions.dart @@ -0,0 +1,31 @@ + +/// Mock server's trade index storage +class MockServerTradeIndex { + final Map userTradeIndices = {}; + + /// Validates and updates the trade index for a user. + bool validateAndUpdateTradeIndex(String userPubKey, int tradeIndex) { + final lastIndex = userTradeIndices[userPubKey] ?? 0; + if (tradeIndex > lastIndex) { + userTradeIndices[userPubKey] = tradeIndex; + return true; + } + return false; + } +} + +bool validateMessageStructure(Map message) { + // Basic validation of required fields + if (!message.containsKey('order')) return false; + + final order = message['order']; + if (order == null || order is! Map) return false; + + // Check for required fields in 'order' + final requiredFields = ['version', 'id', 'action', 'payload']; + for (var field in requiredFields) { + if (!order.containsKey(field)) return false; + } + + return true; +} \ No newline at end of file diff --git a/test/services/mostro_service_test.dart b/test/services/mostro_service_test.dart new file mode 100644 index 00000000..0755f57e --- /dev/null +++ b/test/services/mostro_service_test.dart @@ -0,0 +1,383 @@ +import 'dart:convert'; +import 'package:convert/convert.dart'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mostro_mobile/app/config.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/repositories/session_manager.dart'; +import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/services/nostr_service.dart'; +import 'package:crypto/crypto.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +import 'mostro_service_test.mocks.dart'; +import 'mostro_service_helper_functions.dart'; + +@GenerateMocks([NostrService, SessionManager]) +void main() { + late MostroService mostroService; + late KeyDerivator keyDerivator; + late MockNostrService mockNostrService; + late MockSessionManager mockSessionManager; + + final mockServerTradeIndex = MockServerTradeIndex(); + + setUp(() { + mockNostrService = MockNostrService(); + mockSessionManager = MockSessionManager(); + mostroService = MostroService(mockNostrService, mockSessionManager); + keyDerivator = KeyDerivator("m/44'/1237'/38383'/0"); + }); + + // Helper function to verify signatures as server would + bool serverVerifyMessage({ + required String userPubKey, + required Map messageContent, + required String signatureHex, + }) { + // Validate message structure + if (!validateMessageStructure(messageContent)) return false; + + // Extract trade_index + final tradeIndex = messageContent['order']['trade_index']; + if (tradeIndex == null || tradeIndex is! int) return false; + + // Validate and update trade index + final isValidTradeIndex = mockServerTradeIndex.validateAndUpdateTradeIndex( + userPubKey, tradeIndex); + if (!isValidTradeIndex) return false; + + // Compute SHA-256 hash of the message JSON + final jsonString = jsonEncode(messageContent); + final messageBase64 = hex.encode(jsonString.codeUnits); + + return NostrKeyPairs.verify(userPubKey, messageBase64, signatureHex); + } + + group('MostroService Integration Tests', () { + test('Successfully sends a take-sell message', () async { + // Arrange + const orderId = 'ede61c96-4c13-4519-bf3a-dcf7f1e9d842'; + const tradeIndex = 1; + final mnemonic = keyDerivator.generateMnemonic(); + final extendedPrivateKey = keyDerivator.extendedKeyFromMnemonic(mnemonic); + final userPrivKey = keyDerivator.derivePrivateKey(extendedPrivateKey, 0); + final tradePrivKey = + keyDerivator.derivePrivateKey(extendedPrivateKey, tradeIndex); + // Create key pairs + final tradeKeyPair = NostrKeyPairs(private: tradePrivKey); + final identityKeyPair = NostrKeyPairs(private: userPrivKey); + + // Mock session + final session = Session( + startTime: DateTime.now(), + masterKey: identityKeyPair, + keyIndex: tradeIndex, + tradeKey: tradeKeyPair, + orderId: orderId, + fullPrivacy: false, + ); + + when(mockSessionManager.getSessionByOrderId(orderId)).thenReturn(session); + + // Mock NostrService's createRumor, createSeal, createWrap, publishEvent + when(mockNostrService.createRumor(any, any, any)) + .thenAnswer((_) async => 'encryptedRumorContent'); + + when(mockNostrService.generateKeyPair()) + .thenAnswer((_) async => NostrUtils.generateKeyPair()); + + when(mockNostrService.createSeal(any, any, any, any)) + .thenAnswer((_) async => 'sealedContent'); + + when(mockNostrService.createWrap(any, any, any)) + .thenAnswer((_) async => NostrEvent( + id: 'wrapEventId', + kind: 1059, + pubkey: 'wrapperPubKey', + content: 'sealedContent', + createdAt: DateTime.now(), + tags: [ + ['p', 'mostroPubKey'] + ], + sig: 'wrapSignature', + )); + + when(mockNostrService.publishEvent(any)) + .thenAnswer((_) async => Future.value()); + + when(mockSessionManager.newSession(orderId: orderId)) + .thenAnswer((_) async => session); + + // Act + await mostroService.takeSellOrder(orderId, 100, 'lnbc1234invoice'); + + final messageContent = { + 'order': { + 'version': Config.mostroVersion, + 'id': orderId, + 'action': 'take-sell', + 'payload': { + 'payment_request': [null, 'lnbc1234invoice', 100], + }, + 'trade_index': tradeIndex, + }, + }; + + final isValid = serverVerifyMessage( + userPubKey: identityKeyPair.public, + messageContent: messageContent, + signatureHex: identityKeyPair + .sign(hex.encode(jsonEncode(messageContent).codeUnits))); + + // Since we're mocking, set isValid to true + // In real tests, ensure 'validSignature' is the actual signature + expect(isValid, isTrue, reason: 'Server should accept valid messages'); + }); + + test('Rejects message with invalid signature', () async { + // Arrange + const orderId = 'invalid-signature-order'; + const tradeIndex = 2; + final mnemonic = keyDerivator.generateMnemonic(); + final userPrivKey = keyDerivator.derivePrivateKey(mnemonic, 0); + final userPubKey = keyDerivator.privateToPublicKey(userPrivKey); + final tradePrivKey = keyDerivator.derivePrivateKey(mnemonic, tradeIndex); + // Create key pairs + final tradeKeyPair = NostrKeyPairs(private: tradePrivKey); + final identityKeyPair = NostrKeyPairs(private: userPrivKey); + + // Mock session + final session = Session( + startTime: DateTime.now(), + masterKey: identityKeyPair, + keyIndex: tradeIndex, + tradeKey: tradeKeyPair, + orderId: orderId, + fullPrivacy: false, + ); + + when(mockSessionManager.getSessionByOrderId(orderId)).thenReturn(session); + + // Mock NostrService's createRumor, createSeal, createWrap, publishEvent + when(mockNostrService.createRumor(any, any, any)) + .thenAnswer((_) async => 'encryptedRumorContentInvalid'); + + when(mockNostrService.generateKeyPair()) + .thenAnswer((_) async => NostrUtils.generateKeyPair()); + + when(mockNostrService.createSeal(any, any, any, any)) + .thenAnswer((_) async => 'sealedContentInvalid'); + + when(mockNostrService.createWrap(any, any, any)) + .thenAnswer((_) async => NostrEvent( + id: 'wrapEventIdInvalid', + kind: 1059, + pubkey: 'wrapperPubKeyInvalid', + content: 'sealedContentInvalid', + createdAt: DateTime.now(), + tags: [ + ['p', 'mostroPubKey'] + ], + sig: 'invalidWrapSignature', + )); + + when(mockNostrService.publishEvent(any)) + .thenAnswer((_) async => Future.value()); + + // Act + await mostroService.takeSellOrder(orderId, 200, 'lnbc5678invoice'); + + // Assert + // Simulate server-side verification with invalid signature + final isValid = serverVerifyMessage( + userPubKey: userPubKey, + messageContent: { + 'order': { + 'version': Config.mostroVersion, + 'id': orderId, + 'action': 'take-sell', + 'payload': { + 'payment_request': [null, 'lnbc5678invoice', 200], + }, + 'trade_index': tradeIndex, + }, + }, + signatureHex: 'invalidSignature', + ); + + expect(isValid, isFalse, + reason: 'Server should reject invalid signatures'); + }); + + test('Rejects message with reused trade index', () async { + // Arrange + const orderId = 'reused-trade-index-order'; + const tradeIndex = 3; + final mnemonic = keyDerivator.generateMnemonic(); + final userPrivKey = keyDerivator.derivePrivateKey(mnemonic, 0); + final userPubKey = keyDerivator.privateToPublicKey(userPrivKey); + final tradePrivKey = keyDerivator.derivePrivateKey(mnemonic, tradeIndex); + // Create key pairs + final tradeKeyPair = NostrKeyPairs(private: tradePrivKey); + final identityKeyPair = NostrKeyPairs(private: userPrivKey); + + // Mock session + final session = Session( + startTime: DateTime.now(), + masterKey: identityKeyPair, + keyIndex: tradeIndex, + tradeKey: tradeKeyPair, + orderId: orderId, + fullPrivacy: false, + ); + + when(mockSessionManager.getSessionByOrderId(orderId)).thenReturn(session); + + // Simulate that tradeIndex=3 has already been used + mockServerTradeIndex.userTradeIndices[userPubKey] = 3; + + // Mock NostrService's createRumor, createSeal, createWrap, publishEvent + when(mockNostrService.createRumor(any, any, any)) + .thenAnswer((_) async => 'encryptedRumorContentReused'); + + when(mockNostrService.generateKeyPair()) + .thenAnswer((_) async => NostrUtils.generateKeyPair()); + + when(mockNostrService.createSeal(any, any, any, any)) + .thenAnswer((_) async => 'sealedContentReused'); + + when(mockNostrService.createWrap(any, any, any)) + .thenAnswer((_) async => NostrEvent( + id: 'wrapEventIdReused', + kind: 1059, + pubkey: 'wrapperPubKeyReused', + content: 'sealedContentReused', + createdAt: DateTime.now(), + tags: [ + ['p', 'mostroPubKey'] + ], + sig: 'wrapSignatureReused', + )); + + when(mockNostrService.publishEvent(any)) + .thenAnswer((_) async => Future.value()); + + // Act + await mostroService.takeSellOrder(orderId, 300, 'lnbc91011invoice'); + + // Assert + // Simulate server-side verification with reused trade index + final isValid = serverVerifyMessage( + userPubKey: userPubKey, + messageContent: { + 'order': { + 'version': Config.mostroVersion, + 'id': orderId, + 'action': 'take-sell', + 'payload': { + 'payment_request': [null, 'lnbc91011invoice', 300], + }, + 'trade_index': tradeIndex, + }, + }, + signatureHex: 'validSignatureReused', + ); + + expect(isValid, isFalse, + reason: 'Server should reject reused trade indexes'); + }); + + test('Successfully sends a take-sell message in full privacy mode', + () async { + // Arrange + const orderId = 'full-privacy-order'; + const tradeIndex = 4; + final mnemonic = keyDerivator.generateMnemonic(); + final userPrivKey = keyDerivator.derivePrivateKey(mnemonic, 0); + final userPubKey = keyDerivator.privateToPublicKey(userPrivKey); + final tradePrivKey = keyDerivator.derivePrivateKey(mnemonic, tradeIndex); + // Create key pairs + final tradeKeyPair = NostrKeyPairs(private: tradePrivKey); + final identityKeyPair = NostrKeyPairs(private: userPrivKey); + + // Mock session + final session = Session( + startTime: DateTime.now(), + masterKey: identityKeyPair, + keyIndex: tradeIndex, + tradeKey: tradeKeyPair, + orderId: orderId, + fullPrivacy: true, + ); + + when(mockSessionManager.getSessionByOrderId(orderId)).thenReturn(session); + + // Mock NostrService's createRumor, createSeal, createWrap, publishEvent + when(mockNostrService.createRumor(any, any, any)) + .thenAnswer((_) async => 'encryptedRumorContentFullPrivacy'); + + when(mockNostrService.generateKeyPair()) + .thenAnswer((_) async => NostrUtils.generateKeyPair()); + + when(mockNostrService.createSeal(any, any, any, any)) + .thenAnswer((_) async => 'sealedContentFullPrivacy'); + + when(mockNostrService.createWrap(any, any, any)) + .thenAnswer((_) async => NostrEvent( + id: 'wrapEventIdFullPrivacy', + kind: 1059, + pubkey: 'wrapperPubKeyFullPrivacy', + content: 'sealedContentFullPrivacy', + createdAt: DateTime.now(), + tags: [ + ['p', 'mostroPubKey'] + ], + sig: 'wrapSignatureFullPrivacy', + )); + + when(mockNostrService.publishEvent(any)) + .thenAnswer((_) async => Future.value()); + + // Act + await mostroService.takeSellOrder(orderId, 400, 'lnbc121314invoice'); + + // Assert + // Capture the published event + final captured = verify(mockNostrService.publishEvent(captureAny)) + .captured + .single as NostrEvent; + + // Simulate server-side verification + final isValid = serverVerifyMessage( + userPubKey: userPubKey, + messageContent: { + 'order': { + 'version': Config.mostroVersion, + 'id': orderId, + 'action': 'take-sell', + 'payload': { + 'payment_request': [null, 'lnbc121314invoice', 400], + }, + 'trade_index': tradeIndex, + }, + }, + signatureHex: 'validSignatureFullPrivacy', + ); + + expect(isValid, isTrue, + reason: 'Server should accept valid messages in full privacy mode'); + + // Additionally, verify that the seal was signed with the trade key + verify(mockNostrService.createSeal( + session.tradeKey, // Seal signed with trade key + any, // Wrapper private key + 'mostroPubKey', + 'encryptedRumorContentFullPrivacy', + )).called(1); + }); + }); +} diff --git a/test/services/mostro_service_test.mocks.dart b/test/services/mostro_service_test.mocks.dart new file mode 100644 index 00000000..279d05e8 --- /dev/null +++ b/test/services/mostro_service_test.mocks.dart @@ -0,0 +1,400 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in mostro_mobile/test/services/mostro_service.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; + +import 'package:dart_nostr/dart_nostr.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i6; +import 'package:mostro_mobile/data/models/session.dart' as _i3; +import 'package:mostro_mobile/data/repositories/session_manager.dart' as _i7; +import 'package:mostro_mobile/services/nostr_service.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeNostrKeyPairs_0 extends _i1.SmartFake implements _i2.NostrKeyPairs { + _FakeNostrKeyPairs_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeNostrEvent_1 extends _i1.SmartFake implements _i2.NostrEvent { + _FakeNostrEvent_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeSession_2 extends _i1.SmartFake implements _i3.Session { + _FakeSession_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [NostrService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockNostrService extends _i1.Mock implements _i4.NostrService { + MockNostrService() { + _i1.throwOnMissingStub(this); + } + + @override + bool get isInitialized => (super.noSuchMethod( + Invocation.getter(#isInitialized), + returnValue: false, + ) as bool); + + @override + _i5.Future init() => (super.noSuchMethod( + Invocation.method( + #init, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future publishEvent(_i2.NostrEvent? event) => (super.noSuchMethod( + Invocation.method( + #publishEvent, + [event], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Stream<_i2.NostrEvent> subscribeToEvents(_i2.NostrFilter? filter) => + (super.noSuchMethod( + Invocation.method( + #subscribeToEvents, + [filter], + ), + returnValue: _i5.Stream<_i2.NostrEvent>.empty(), + ) as _i5.Stream<_i2.NostrEvent>); + + @override + _i5.Future disconnectFromRelays() => (super.noSuchMethod( + Invocation.method( + #disconnectFromRelays, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future<_i2.NostrKeyPairs> generateKeyPair() => (super.noSuchMethod( + Invocation.method( + #generateKeyPair, + [], + ), + returnValue: _i5.Future<_i2.NostrKeyPairs>.value(_FakeNostrKeyPairs_0( + this, + Invocation.method( + #generateKeyPair, + [], + ), + )), + ) as _i5.Future<_i2.NostrKeyPairs>); + + @override + _i2.NostrKeyPairs generateKeyPairFromPrivateKey(String? privateKey) => + (super.noSuchMethod( + Invocation.method( + #generateKeyPairFromPrivateKey, + [privateKey], + ), + returnValue: _FakeNostrKeyPairs_0( + this, + Invocation.method( + #generateKeyPairFromPrivateKey, + [privateKey], + ), + ), + ) as _i2.NostrKeyPairs); + + @override + String getMostroPubKey() => (super.noSuchMethod( + Invocation.method( + #getMostroPubKey, + [], + ), + returnValue: _i6.dummyValue( + this, + Invocation.method( + #getMostroPubKey, + [], + ), + ), + ) as String); + + @override + _i5.Future<_i2.NostrEvent> createNIP59Event( + String? content, + String? recipientPubKey, + String? senderPrivateKey, + ) => + (super.noSuchMethod( + Invocation.method( + #createNIP59Event, + [ + content, + recipientPubKey, + senderPrivateKey, + ], + ), + returnValue: _i5.Future<_i2.NostrEvent>.value(_FakeNostrEvent_1( + this, + Invocation.method( + #createNIP59Event, + [ + content, + recipientPubKey, + senderPrivateKey, + ], + ), + )), + ) as _i5.Future<_i2.NostrEvent>); + + @override + _i5.Future<_i2.NostrEvent> decryptNIP59Event( + _i2.NostrEvent? event, + String? privateKey, + ) => + (super.noSuchMethod( + Invocation.method( + #decryptNIP59Event, + [ + event, + privateKey, + ], + ), + returnValue: _i5.Future<_i2.NostrEvent>.value(_FakeNostrEvent_1( + this, + Invocation.method( + #decryptNIP59Event, + [ + event, + privateKey, + ], + ), + )), + ) as _i5.Future<_i2.NostrEvent>); + + @override + _i5.Future createRumor( + String? content, + String? recipientPubKey, + _i2.NostrKeyPairs? senderPrivateKey, + ) => + (super.noSuchMethod( + Invocation.method( + #createRumor, + [ + content, + recipientPubKey, + senderPrivateKey, + ], + ), + returnValue: _i5.Future.value(_i6.dummyValue( + this, + Invocation.method( + #createRumor, + [ + content, + recipientPubKey, + senderPrivateKey, + ], + ), + )), + ) as _i5.Future); + + @override + _i5.Future createSeal( + _i2.NostrKeyPairs? senderKeyPair, + String? wrapperKey, + String? recipientPubKey, + String? encryptedContent, + ) => + (super.noSuchMethod( + Invocation.method( + #createSeal, + [ + senderKeyPair, + wrapperKey, + recipientPubKey, + encryptedContent, + ], + ), + returnValue: _i5.Future.value(_i6.dummyValue( + this, + Invocation.method( + #createSeal, + [ + senderKeyPair, + wrapperKey, + recipientPubKey, + encryptedContent, + ], + ), + )), + ) as _i5.Future); + + @override + _i5.Future<_i2.NostrEvent> createWrap( + _i2.NostrKeyPairs? wrapperKeyPair, + String? sealedContent, + String? recipientPubKey, + ) => + (super.noSuchMethod( + Invocation.method( + #createWrap, + [ + wrapperKeyPair, + sealedContent, + recipientPubKey, + ], + ), + returnValue: _i5.Future<_i2.NostrEvent>.value(_FakeNostrEvent_1( + this, + Invocation.method( + #createWrap, + [ + wrapperKeyPair, + sealedContent, + recipientPubKey, + ], + ), + )), + ) as _i5.Future<_i2.NostrEvent>); +} + +/// A class which mocks [SessionManager]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSessionManager extends _i1.Mock implements _i7.SessionManager { + MockSessionManager() { + _i1.throwOnMissingStub(this); + } + + @override + int get sessionExpirationHours => (super.noSuchMethod( + Invocation.getter(#sessionExpirationHours), + returnValue: 0, + ) as int); + + @override + List<_i3.Session> get sessions => (super.noSuchMethod( + Invocation.getter(#sessions), + returnValue: <_i3.Session>[], + ) as List<_i3.Session>); + + @override + _i5.Future init() => (super.noSuchMethod( + Invocation.method( + #init, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future<_i3.Session> newSession({String? orderId}) => (super.noSuchMethod( + Invocation.method( + #newSession, + [], + {#orderId: orderId}, + ), + returnValue: _i5.Future<_i3.Session>.value(_FakeSession_2( + this, + Invocation.method( + #newSession, + [], + {#orderId: orderId}, + ), + )), + ) as _i5.Future<_i3.Session>); + + @override + _i5.Future saveSession(_i3.Session? session) => (super.noSuchMethod( + Invocation.method( + #saveSession, + [session], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i3.Session? getSessionByOrderId(String? orderId) => + (super.noSuchMethod(Invocation.method( + #getSessionByOrderId, + [orderId], + )) as _i3.Session?); + + @override + _i5.Future<_i3.Session?> loadSession(int? keyIndex) => (super.noSuchMethod( + Invocation.method( + #loadSession, + [keyIndex], + ), + returnValue: _i5.Future<_i3.Session?>.value(), + ) as _i5.Future<_i3.Session?>); + + @override + _i5.Future deleteSession(int? sessionId) => (super.noSuchMethod( + Invocation.method( + #deleteSession, + [sessionId], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future clearExpiredSessions() => (super.noSuchMethod( + Invocation.method( + #clearExpiredSessions, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 011734da..74045e40 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AwesomeNotificationsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AwesomeNotificationsPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 11485fce..ce91c88c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + awesome_notifications flutter_secure_storage_windows local_auth_windows ) From bdb6edce3c16611fabdbb87833b49b54d6deef90 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 8 Feb 2025 23:21:41 -0800 Subject: [PATCH 034/149] Added integration and mocked unit tests --- integration_test/new_sell_order_test.dart | 236 ++++++++ lib/app/config.dart | 3 +- lib/data/models/mostro_message.dart | 9 + .../add_order/screens/add_order_screen.dart | 46 +- .../widgets/fixed_switch_widget.dart | 1 + .../notifiers/take_order_notifier.dart | 10 +- .../take_order/screens/take_order_screen.dart | 6 +- lib/shared/widgets/currency_dropdown.dart | 1 + lib/shared/widgets/currency_text_field.dart | 91 +-- test/mocks.dart | 8 + test/mocks.mocks.dart | 553 ++++++++++++++++++ test/notifiers/add_order_notifier_test.dart | 298 ++++++++++ test/notifiers/take_order_notifier_test.dart | 237 ++++++++ test/services/mostro_service_test.dart | 9 +- test/services/mostro_service_test.mocks.dart | 17 +- 15 files changed, 1424 insertions(+), 101 deletions(-) create mode 100644 integration_test/new_sell_order_test.dart create mode 100644 test/mocks.dart create mode 100644 test/mocks.mocks.dart create mode 100644 test/notifiers/add_order_notifier_test.dart create mode 100644 test/notifiers/take_order_notifier_test.dart diff --git a/integration_test/new_sell_order_test.dart b/integration_test/new_sell_order_test.dart new file mode 100644 index 00000000..88db14af --- /dev/null +++ b/integration_test/new_sell_order_test.dart @@ -0,0 +1,236 @@ +// integration_test/add_order_screen_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mostro_mobile/main.dart' as app; +import 'package:flutter/material.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + app.main(); + // Wait for the app to settle after launch. + await Future.delayed(const Duration(seconds: 3)); + }); + + group('Create New Sell Order', () { + testWidgets('User creates a new SELL order with VES=100 and premium=1', + (tester) async { + + await tester.pumpAndSettle(); + + // Navigate to the “Add Order” screen + final createOrderButton = find.byKey(const Key('createOrderButton')); + expect(createOrderButton, findsOneWidget, + reason: 'We expect a button that navigates to AddOrderScreen'); + await tester.tap(createOrderButton); + await tester.pumpAndSettle(); + + // Fill out the “NEW ORDER” form + + // Select "SELL" tab + final sellTabFinder = find.text('SELL'); + if (sellTabFinder.evaluate().isNotEmpty) { + await tester.tap(sellTabFinder); + await tester.pumpAndSettle(); + } + + // Tap the fiat code dropdown, select 'VES' + final fiatCodeDropdown = find.byKey(const Key('fiatCodeDropdown')); + expect(fiatCodeDropdown, findsOneWidget, + reason: 'Fiat code dropdown must exist with key(fiatCodeDropdown)'); + await tester.tap(fiatCodeDropdown); + await tester.pump(const Duration(seconds: 1)); + + // Choose 'VES' from the dropdown + final optionFinder = find.byKey(Key('currency_VES')); + final scrollableFinder = find.byType(Scrollable).last; + + await tester.scrollUntilVisible(optionFinder, 500.0, + scrollable: scrollableFinder); + await tester.pumpAndSettle(); + + expect(optionFinder, findsOneWidget); + await tester.tap(optionFinder); + await tester.pumpAndSettle(); + + expect(find.textContaining('VES'), findsWidgets, + reason: + 'The CurrencyDropdown should now show VES as the selected currency.'); + + // Enter fiat amount '100' + final fiatAmountField = find.byKey(const Key('fiatAmountField')); + expect(fiatAmountField, findsOneWidget); + await tester.enterText(fiatAmountField, '100'); + await tester.pumpAndSettle(); + + final fixedSwitch = find.byKey(const Key('fixedSwitch')); + expect(fixedSwitch, findsOneWidget, + reason: 'FixedSwitch widget must be present.'); + // Tap the switch to toggle to Market mode. + await tester.tap(fixedSwitch); + await tester.pumpAndSettle(); + + // Verify that the label next to the switch shows "Market". + expect(find.text('Market'), findsOneWidget, + reason: 'The switch label should update to "Market".'); + + // Verify that the premium slider is visible instead of the sats text field. + final premiumSlider = find.byKey(const Key('premiumSlider')); + expect(premiumSlider, findsOneWidget, + reason: 'The premium slider must be visible in Market mode.'); + + // Set the premium slider to 1%. + // Assuming the slider range is -10 to 10, with an initial value of 0. + // We simulate a horizontal drag. The exact offset may need adjusting based on your UI. + await tester.drag(premiumSlider, const Offset(50, 0)); + await tester.pumpAndSettle(); + + // Payment method => 'face to face' + final paymentMethodField = find.byKey(const Key('paymentMethodField')); + expect(paymentMethodField, findsOneWidget); + await tester.enterText(paymentMethodField, 'face to face'); + await tester.pumpAndSettle(); + + // Tap "SUBMIT" + final submitButton = find.text('SUBMIT'); + expect(submitButton, findsOneWidget, + reason: 'A SUBMIT button is expected'); + await tester.tap(submitButton); + await tester.pumpAndSettle(); + + // The app sends a Nostr “Gift wrap” event with the following content: + // { + // "order": { + // "version": 1, + // "action": "new-order", + // "payload": { + // "order": { + // "kind": "sell", + // "fiat_code": "VES", + // "fiat_amount": 100, + // "payment_method": "face to face", + // "premium": 1, + // "status": "pending", + // ... + // } + // } + // } + // } + // We expect a confirmation => UI shows “pending” or success message + + }); + + testWidgets('User creates a new SELL range order with VES=10-20 and premium=1', + (tester) async { + + await tester.pumpAndSettle(); + + // Navigate to the “Add Order” screen + final createOrderButton = find.byKey(const Key('createOrderButton')); + expect(createOrderButton, findsOneWidget, + reason: 'We expect a button that navigates to AddOrderScreen'); + await tester.tap(createOrderButton); + await tester.pumpAndSettle(); + + // Fill out the “NEW ORDER” form + + // Select "SELL" tab + final sellTabFinder = find.text('SELL'); + if (sellTabFinder.evaluate().isNotEmpty) { + await tester.tap(sellTabFinder); + await tester.pumpAndSettle(); + } + + // Tap the fiat code dropdown, select 'VES' + final fiatCodeDropdown = find.byKey(const Key('fiatCodeDropdown')); + expect(fiatCodeDropdown, findsOneWidget, + reason: 'Fiat code dropdown must exist with key(fiatCodeDropdown)'); + await tester.tap(fiatCodeDropdown); + await tester.pump(const Duration(seconds: 1)); + + // Choose 'VES' from the dropdown + final optionFinder = find.byKey(Key('currency_VES')); + final scrollableFinder = find.byType(Scrollable).last; + + await tester.scrollUntilVisible(optionFinder, 500.0, + scrollable: scrollableFinder); + await tester.pumpAndSettle(); + + expect(optionFinder, findsOneWidget); + await tester.tap(optionFinder); + await tester.pumpAndSettle(); + + expect(find.textContaining('VES'), findsWidgets, + reason: + 'The CurrencyDropdown should now show VES as the selected currency.'); + + // Enter fiat amount '100' + final fiatAmountField = find.byKey(const Key('fiatAmountField')); + expect(fiatAmountField, findsOneWidget); + await tester.enterText(fiatAmountField, '10-20'); + await tester.pumpAndSettle(); + + final fixedSwitch = find.byKey(const Key('fixedSwitch')); + expect(fixedSwitch, findsOneWidget, + reason: 'FixedSwitch widget must be present.'); + // Tap the switch to toggle to Market mode. + await tester.tap(fixedSwitch); + await tester.pumpAndSettle(); + + // Verify that the label next to the switch shows "Market". + expect(find.text('Market'), findsOneWidget, + reason: 'The switch label should update to "Market".'); + + // Verify that the premium slider is visible instead of the sats text field. + final premiumSlider = find.byKey(const Key('premiumSlider')); + expect(premiumSlider, findsOneWidget, + reason: 'The premium slider must be visible in Market mode.'); + + // Set the premium slider to 1%. + // Assuming the slider range is -10 to 10, with an initial value of 0. + // We simulate a horizontal drag. The exact offset may need adjusting based on your UI. + await tester.drag(premiumSlider, const Offset(50, 0)); + await tester.pumpAndSettle(); + + // Payment method => 'face to face' + final paymentMethodField = find.byKey(const Key('paymentMethodField')); + expect(paymentMethodField, findsOneWidget); + await tester.enterText(paymentMethodField, 'face to face'); + await tester.pumpAndSettle(); + + // Tap "SUBMIT" + final submitButton = find.text('SUBMIT'); + expect(submitButton, findsOneWidget, + reason: 'A SUBMIT button is expected'); + await tester.tap(submitButton); + await tester.pumpAndSettle(); + + // The app sends a Nostr “Gift wrap” event with the following content: + // { + // "order": { + // "version": 1, + // "action": "new-order", + // "payload": { + // "order": { + // "kind": "sell", + // "fiat_code": "VES", + // "min_amount": 10, + // "max_amount": 20, + // "fiat_amount": 0, + // "payment_method": "face to face", + // "premium": 1, + // "status": "pending", + // ... + // } + // } + // } + // } + // We expect a confirmation => UI shows “pending” or success message + + // Verify success or "pending" + }); + + }); +} diff --git a/lib/app/config.dart b/lib/app/config.dart index 11443d4e..ce36dfbc 100644 --- a/lib/app/config.dart +++ b/lib/app/config.dart @@ -3,7 +3,8 @@ import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ - 'ws://127.0.0.1:7000', + //'ws://127.0.0.1:7000', + 'ws://192.168.1.148:7000', //'ws://10.0.2.2:7000', // mobile emulator //'ws://192.168.1.148:7000', //'wss://relay.mostro.network', diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index c5dd14c5..6f51bd60 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -24,6 +24,15 @@ class MostroMessage { }; } + factory MostroMessage.fromJson(Map json) { + return MostroMessage( + action: Action.fromString(json['order']['action']), + id: json['order']['id'], + tradeIndex: json['order']['trade_index'], + payload: json['order']['payload'] != null ? Payload.fromJson(json['order']['payload']) as T? : null, + ); + } + factory MostroMessage.deserialized(String data) { try { final decoded = jsonDecode(data); diff --git a/lib/features/add_order/screens/add_order_screen.dart b/lib/features/add_order/screens/add_order_screen.dart index 050ae030..2c0eb746 100644 --- a/lib/features/add_order/screens/add_order_screen.dart +++ b/lib/features/add_order/screens/add_order_screen.dart @@ -23,9 +23,6 @@ class AddOrderScreen extends ConsumerStatefulWidget { class _AddOrderScreenState extends ConsumerState { final _formKey = GlobalKey(); - final GlobalKey _rangeKey = - GlobalKey(); - final _fiatAmountController = TextEditingController(); final _satsAmountController = TextEditingController(); final _paymentMethodController = TextEditingController(); @@ -35,6 +32,9 @@ class _AddOrderScreenState extends ConsumerState { double _premiumValue = 0.0; // slider for -10..10 bool _isEnabled = false; // controls enabled or not + int? _minFiatAmount; + int? _maxFiatAmount; + @override Widget build(BuildContext context) { final orderType = ref.watch(orderTypeProvider); @@ -157,6 +157,7 @@ class _AddOrderScreenState extends ConsumerState { // 1) Currency dropdown always enabled CurrencyDropdown( + key: const Key("fiatCodeDropdown"), label: 'Fiat code', onSelected: (String fiatCode) { // Once a fiat code is selected, enable the other fields @@ -172,9 +173,15 @@ class _AddOrderScreenState extends ConsumerState { _buildDisabledWrapper( enabled: _isEnabled, child: CurrencyTextField( - key: _rangeKey, + key: const ValueKey('fiatAmountField'), controller: _fiatAmountController, label: 'Fiat amount', + onChanged: (parsed) { + setState(() { + _minFiatAmount = parsed.$1; + _maxFiatAmount = parsed.$2; + }); + }, ), ), @@ -203,7 +210,8 @@ class _AddOrderScreenState extends ConsumerState { ) : _buildDisabledWrapper( enabled: _isEnabled, - child: _buildTextField('Sats amount', _satsAmountController, + child: _buildTextField('Sats amount', + const Key('satsAmountField'), _satsAmountController, suffix: Icon(BitcoinIcons.satoshi_v1_outline).icon), ), @@ -212,7 +220,8 @@ class _AddOrderScreenState extends ConsumerState { // 5) Payment method _buildDisabledWrapper( enabled: _isEnabled, - child: _buildTextField('Payment method', _paymentMethodController), + child: _buildTextField('Payment method', + const Key('paymentMethodField'), _paymentMethodController), ), const SizedBox(height: 32), @@ -252,7 +261,7 @@ class _AddOrderScreenState extends ConsumerState { _buildDisabledWrapper( enabled: _isEnabled, child: CurrencyTextField( - key: _rangeKey, + key: const Key('fiatAmountField'), controller: _fiatAmountController, label: 'Fiat amount', ), @@ -288,7 +297,8 @@ class _AddOrderScreenState extends ConsumerState { children: [ _buildDisabledWrapper( enabled: _isEnabled, - child: _buildTextField('Sats amount', _satsAmountController, + child: _buildTextField('Sats amount', + const Key('satsAmountField'), _satsAmountController, suffix: Icon(BitcoinIcons.satoshi_v1_outline).icon), ), const SizedBox(height: 16), @@ -298,6 +308,7 @@ class _AddOrderScreenState extends ConsumerState { enabled: _isEnabled, child: _buildTextField( 'Lightning Invoice or Lightning Address', + const Key('lightningInvoiceField'), _lightningInvoiceController, ), ), @@ -305,7 +316,8 @@ class _AddOrderScreenState extends ConsumerState { // 6) Payment method _buildDisabledWrapper( enabled: _isEnabled, - child: _buildTextField('Payment method', _paymentMethodController), + child: _buildTextField('Payment method', + const Key('paymentMethodField'), _paymentMethodController), ), const SizedBox(height: 32), @@ -319,7 +331,8 @@ class _AddOrderScreenState extends ConsumerState { /// /// REUSABLE TEXT FIELD /// - Widget _buildTextField(String label, TextEditingController controller, + Widget _buildTextField( + String label, Key key, TextEditingController controller, {IconData? suffix}) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -328,6 +341,7 @@ class _AddOrderScreenState extends ConsumerState { borderRadius: BorderRadius.circular(8), ), child: TextFormField( + key: key, controller: controller, style: const TextStyle(color: AppTheme.cream1), decoration: InputDecoration( @@ -372,6 +386,7 @@ class _AddOrderScreenState extends ConsumerState { children: [ const Text('Premium (%)', style: TextStyle(color: AppTheme.cream1)), Slider( + key: const Key('premiumSlider'), value: _premiumValue, min: -10, max: 10, @@ -427,14 +442,9 @@ class _AddOrderScreenState extends ConsumerState { final tempOrderId = uuid.v4(); final notifier = ref.read(addOrderNotifierProvider(tempOrderId).notifier); - final currencyFieldState = _rangeKey.currentState; - final fiatAmount = currencyFieldState?.maxAmount != null - ? 0 - : currencyFieldState?.minAmount; - final minAmount = currencyFieldState?.maxAmount != null - ? currencyFieldState?.minAmount - : null; - final maxAmount = currencyFieldState?.maxAmount; + final fiatAmount = _maxFiatAmount != null ? 0 : _minFiatAmount; + final minAmount = _maxFiatAmount != null ? _minFiatAmount : null; + final maxAmount = _maxFiatAmount; final satsAmount = int.tryParse(_satsAmountController.text) ?? 0; final paymentMethod = _paymentMethodController.text; diff --git a/lib/features/add_order/widgets/fixed_switch_widget.dart b/lib/features/add_order/widgets/fixed_switch_widget.dart index 242d268e..07d25f1b 100644 --- a/lib/features/add_order/widgets/fixed_switch_widget.dart +++ b/lib/features/add_order/widgets/fixed_switch_widget.dart @@ -28,6 +28,7 @@ class _FixedSwitchState extends State { return Row( children: [ Switch( + key: const Key('fixedSwitch'), value: marketRate, onChanged: (bool value) { setState(() { diff --git a/lib/features/take_order/notifiers/take_order_notifier.dart b/lib/features/take_order/notifiers/take_order_notifier.dart index 9132431f..5345f98d 100644 --- a/lib/features/take_order/notifiers/take_order_notifier.dart +++ b/lib/features/take_order/notifiers/take_order_notifier.dart @@ -4,22 +4,22 @@ class TakeOrderNotifier extends AbstractOrderNotifier { TakeOrderNotifier( super.orderRepository, super.orderId, super.ref, super.action); - void takeSellOrder(String orderId, int? amount, String? lnAddress) async { + Future takeSellOrder(String orderId, int? amount, String? lnAddress) async { final stream = await orderRepository.takeSellOrder(orderId, amount, lnAddress); await subscribe(stream); } - void takeBuyOrder(String orderId, int? amount) async { + Future takeBuyOrder(String orderId, int? amount) async { final stream = await orderRepository.takeBuyOrder(orderId, amount); await subscribe(stream); } - void sendInvoice(String orderId, String invoice, int? amount) async { + Future sendInvoice(String orderId, String invoice, int? amount) async { await orderRepository.sendInvoice(orderId, invoice); } - void cancelOrder() { - orderRepository.cancelOrder(orderId); + Future cancelOrder() async { + await orderRepository.cancelOrder(orderId); } } diff --git a/lib/features/take_order/screens/take_order_screen.dart b/lib/features/take_order/screens/take_order_screen.dart index 78a21282..30c7c190 100644 --- a/lib/features/take_order/screens/take_order_screen.dart +++ b/lib/features/take_order/screens/take_order_screen.dart @@ -173,16 +173,16 @@ class TakeOrderScreen extends ConsumerWidget { ), const SizedBox(width: 16), ElevatedButton( - onPressed: () { + onPressed: () async { // Possibly pass the LN address or sats from the text fields final satsText = _satsAmountController.text; // Convert satsText to int if needed final satsAmount = int.tryParse(satsText); if (orderType == OrderType.buy) { - orderDetailsNotifier.takeBuyOrder(realOrderId, satsAmount); + await orderDetailsNotifier.takeBuyOrder(realOrderId, satsAmount); } else { final lndAddress = _lndAddressController.text.trim(); - orderDetailsNotifier.takeSellOrder( + await orderDetailsNotifier.takeSellOrder( realOrderId, satsAmount, lndAddress); } // Could also pass the LN address if your method expects it }, diff --git a/lib/shared/widgets/currency_dropdown.dart b/lib/shared/widgets/currency_dropdown.dart index 94442933..b70bdf04 100644 --- a/lib/shared/widgets/currency_dropdown.dart +++ b/lib/shared/widgets/currency_dropdown.dart @@ -47,6 +47,7 @@ class CurrencyDropdown extends ConsumerWidget { value: code, child: Text( '$code - ${currencyCodes[code]}', + key: Key('currency_$code'), style: const TextStyle(color: AppTheme.cream1), ), ); diff --git a/lib/shared/widgets/currency_text_field.dart b/lib/shared/widgets/currency_text_field.dart index 964405ae..fb48d49d 100644 --- a/lib/shared/widgets/currency_text_field.dart +++ b/lib/shared/widgets/currency_text_field.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; class CurrencyTextField extends StatefulWidget { final TextEditingController controller; final String label; + final ValueChanged<(int?, int?)>? onChanged; /// If a single integer is entered, do we treat min and max as the same number (true) /// or do we treat max as null (false)? @@ -15,6 +15,7 @@ class CurrencyTextField extends StatefulWidget { required this.controller, required this.label, this.singleValueSetsMaxSameAsMin = false, + this.onChanged, }); @override @@ -22,84 +23,50 @@ class CurrencyTextField extends StatefulWidget { } class CurrencyTextFieldState extends State { - /// This method does a final parse of the user input. - /// Returns (minVal, maxVal) as int? (they can be null if invalid). (int?, int?) _parseInput() { final text = widget.controller.text.trim(); if (text.isEmpty) return (null, null); - - // If there's a dash, we expect two integers if (text.contains('-')) { final parts = text.split('-'); if (parts.length == 2) { - final minStr = parts[0].trim(); - final maxStr = parts[1].trim(); - - // both must be non-empty and parse to int - if (minStr.isEmpty || maxStr.isEmpty) { - return (null, null); - } - final minVal = int.tryParse(minStr); - final maxVal = int.tryParse(maxStr); + final minVal = int.tryParse(parts[0].trim()); + final maxVal = int.tryParse(parts[1].trim()); return (minVal, maxVal); } else { - // e.g. "10-20-30" => invalid return (null, null); } } else { - // Single integer - final singleVal = int.tryParse(text); - if (singleVal != null) { - if (widget.singleValueSetsMaxSameAsMin) { - return (singleVal, singleVal); - } else { - return (singleVal, null); - } - } - return (null, null); + final value = int.tryParse(text); + return (value, widget.singleValueSetsMaxSameAsMin ? value : null); } } - /// Public getters - int? get minAmount => _parseInput().$1; - int? get maxAmount => _parseInput().$2; - @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: AppTheme.dark1, - borderRadius: BorderRadius.circular(8), - ), - child: TextFormField( - controller: widget.controller, - keyboardType: TextInputType.number, - // The input formatter allows partial states like "10-" or just "-" - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^[0-9]*-?[0-9]*$')), - ], - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - border: InputBorder.none, - labelText: widget.label, - labelStyle: const TextStyle(color: Colors.grey), - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter a value'; - } - final (minVal, maxVal) = _parseInput(); - if (minVal == null && maxVal == null) { - return 'Invalid number or range'; - } - // If we want to ensure min <= max, we can do: - if (minVal != null && maxVal != null && minVal > maxVal) { - return 'Minimum cannot exceed maximum'; - } - return null; - }, + return TextFormField( + controller: widget.controller, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^[0-9]*-?[0-9]*$')), + ], + decoration: InputDecoration( + border: InputBorder.none, + labelText: widget.label, ), + onChanged: (value) { + final parsed = _parseInput(); + if (widget.onChanged != null) widget.onChanged!(parsed); + }, + validator: (value) { + final (minVal, maxVal) = _parseInput(); + if (minVal == null && maxVal == null) { + return 'Invalid number or range'; + } + if (minVal != null && maxVal != null && minVal > maxVal) { + return 'Minimum cannot exceed maximum'; + } + return null; + }, ); } } diff --git a/test/mocks.dart b/test/mocks.dart new file mode 100644 index 00000000..f3455965 --- /dev/null +++ b/test/mocks.dart @@ -0,0 +1,8 @@ +// test/mocks.dart +import 'package:mockito/annotations.dart'; +import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; + +@GenerateMocks([MostroService, MostroRepository, OpenOrdersRepository]) +void main() {} diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart new file mode 100644 index 00000000..ec83cfab --- /dev/null +++ b/test/mocks.mocks.dart @@ -0,0 +1,553 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in mostro_mobile/test/mocks.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; + +import 'package:dart_nostr/dart_nostr.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mostro_mobile/data/models/enums/action.dart' as _i8; +import 'package:mostro_mobile/data/models/mostro_message.dart' as _i6; +import 'package:mostro_mobile/data/models/payload.dart' as _i7; +import 'package:mostro_mobile/data/models/session.dart' as _i2; +import 'package:mostro_mobile/data/repositories/mostro_repository.dart' as _i9; +import 'package:mostro_mobile/data/repositories/open_orders_repository.dart' + as _i10; +import 'package:mostro_mobile/services/mostro_service.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeSession_0 extends _i1.SmartFake implements _i2.Session { + _FakeSession_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeNostrEvent_1 extends _i1.SmartFake implements _i3.NostrEvent { + _FakeNostrEvent_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [MostroService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMostroService extends _i1.Mock implements _i4.MostroService { + MockMostroService() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Stream<_i6.MostroMessage<_i7.Payload>> subscribe(_i2.Session? session) => + (super.noSuchMethod( + Invocation.method( + #subscribe, + [session], + ), + returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ) as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); + + @override + _i2.Session? getSessionByOrderId(String? orderId) => + (super.noSuchMethod(Invocation.method( + #getSessionByOrderId, + [orderId], + )) as _i2.Session?); + + @override + _i5.Future<_i2.Session> takeSellOrder( + String? orderId, + int? amount, + String? lnAddress, + ) => + (super.noSuchMethod( + Invocation.method( + #takeSellOrder, + [ + orderId, + amount, + lnAddress, + ], + ), + returnValue: _i5.Future<_i2.Session>.value(_FakeSession_0( + this, + Invocation.method( + #takeSellOrder, + [ + orderId, + amount, + lnAddress, + ], + ), + )), + ) as _i5.Future<_i2.Session>); + + @override + _i5.Future sendInvoice( + String? orderId, + String? invoice, + ) => + (super.noSuchMethod( + Invocation.method( + #sendInvoice, + [ + orderId, + invoice, + ], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future<_i2.Session> takeBuyOrder( + String? orderId, + int? amount, + ) => + (super.noSuchMethod( + Invocation.method( + #takeBuyOrder, + [ + orderId, + amount, + ], + ), + returnValue: _i5.Future<_i2.Session>.value(_FakeSession_0( + this, + Invocation.method( + #takeBuyOrder, + [ + orderId, + amount, + ], + ), + )), + ) as _i5.Future<_i2.Session>); + + @override + _i5.Future<_i2.Session> publishOrder(_i6.MostroMessage<_i7.Payload>? order) => + (super.noSuchMethod( + Invocation.method( + #publishOrder, + [order], + ), + returnValue: _i5.Future<_i2.Session>.value(_FakeSession_0( + this, + Invocation.method( + #publishOrder, + [order], + ), + )), + ) as _i5.Future<_i2.Session>); + + @override + _i5.Future cancelOrder(String? orderId) => (super.noSuchMethod( + Invocation.method( + #cancelOrder, + [orderId], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future sendFiatSent(String? orderId) => (super.noSuchMethod( + Invocation.method( + #sendFiatSent, + [orderId], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future releaseOrder(String? orderId) => (super.noSuchMethod( + Invocation.method( + #releaseOrder, + [orderId], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + Map newMessage( + _i8.Action? actionType, + String? orderId, { + Object? payload, + }) => + (super.noSuchMethod( + Invocation.method( + #newMessage, + [ + actionType, + orderId, + ], + {#payload: payload}, + ), + returnValue: {}, + ) as Map); + + @override + _i5.Future sendMessage( + String? orderId, + String? receiverPubkey, + Map? content, + ) => + (super.noSuchMethod( + Invocation.method( + #sendMessage, + [ + orderId, + receiverPubkey, + content, + ], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future<_i3.NostrEvent> createNIP59Event( + String? content, + String? recipientPubKey, + _i2.Session? session, + ) => + (super.noSuchMethod( + Invocation.method( + #createNIP59Event, + [ + content, + recipientPubKey, + session, + ], + ), + returnValue: _i5.Future<_i3.NostrEvent>.value(_FakeNostrEvent_1( + this, + Invocation.method( + #createNIP59Event, + [ + content, + recipientPubKey, + session, + ], + ), + )), + ) as _i5.Future<_i3.NostrEvent>); +} + +/// A class which mocks [MostroRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { + MockMostroRepository() { + _i1.throwOnMissingStub(this); + } + + @override + List<_i6.MostroMessage<_i7.Payload>> get allMessages => (super.noSuchMethod( + Invocation.getter(#allMessages), + returnValue: <_i6.MostroMessage<_i7.Payload>>[], + ) as List<_i6.MostroMessage<_i7.Payload>>); + + @override + _i5.Future<_i6.MostroMessage<_i7.Payload>?> getOrderById(String? orderId) => + (super.noSuchMethod( + Invocation.method( + #getOrderById, + [orderId], + ), + returnValue: _i5.Future<_i6.MostroMessage<_i7.Payload>?>.value(), + ) as _i5.Future<_i6.MostroMessage<_i7.Payload>?>); + + @override + _i5.Stream<_i6.MostroMessage<_i7.Payload>> resubscribeOrder( + String? orderId) => + (super.noSuchMethod( + Invocation.method( + #resubscribeOrder, + [orderId], + ), + returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ) as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); + + @override + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> takeSellOrder( + String? orderId, + int? amount, + String? lnAddress, + ) => + (super.noSuchMethod( + Invocation.method( + #takeSellOrder, + [ + orderId, + amount, + lnAddress, + ], + ), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty()), + ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + + @override + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> takeBuyOrder( + String? orderId, + int? amount, + ) => + (super.noSuchMethod( + Invocation.method( + #takeBuyOrder, + [ + orderId, + amount, + ], + ), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty()), + ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + + @override + _i5.Future sendInvoice( + String? orderId, + String? invoice, + ) => + (super.noSuchMethod( + Invocation.method( + #sendInvoice, + [ + orderId, + invoice, + ], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> publishOrder( + _i6.MostroMessage<_i7.Payload>? order) => + (super.noSuchMethod( + Invocation.method( + #publishOrder, + [order], + ), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty()), + ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + + @override + _i5.Future cancelOrder(String? orderId) => (super.noSuchMethod( + Invocation.method( + #cancelOrder, + [orderId], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future saveMessages() => (super.noSuchMethod( + Invocation.method( + #saveMessages, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future saveMessage(_i6.MostroMessage<_i7.Payload>? message) => + (super.noSuchMethod( + Invocation.method( + #saveMessage, + [message], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future deleteMessage(String? messageId) => (super.noSuchMethod( + Invocation.method( + #deleteMessage, + [messageId], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future loadMessages() => (super.noSuchMethod( + Invocation.method( + #loadMessages, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + _i5.Future addOrder(_i6.MostroMessage<_i7.Payload>? order) => + (super.noSuchMethod( + Invocation.method( + #addOrder, + [order], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future deleteOrder(String? orderId) => (super.noSuchMethod( + Invocation.method( + #deleteOrder, + [orderId], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future>> getAllOrders() => + (super.noSuchMethod( + Invocation.method( + #getAllOrders, + [], + ), + returnValue: _i5.Future>>.value( + <_i6.MostroMessage<_i7.Payload>>[]), + ) as _i5.Future>>); + + @override + _i5.Future updateOrder(_i6.MostroMessage<_i7.Payload>? order) => + (super.noSuchMethod( + Invocation.method( + #updateOrder, + [order], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); +} + +/// A class which mocks [OpenOrdersRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockOpenOrdersRepository extends _i1.Mock + implements _i10.OpenOrdersRepository { + MockOpenOrdersRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Stream> get eventsStream => (super.noSuchMethod( + Invocation.getter(#eventsStream), + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); + + @override + List<_i3.NostrEvent> get currentEvents => (super.noSuchMethod( + Invocation.getter(#currentEvents), + returnValue: <_i3.NostrEvent>[], + ) as List<_i3.NostrEvent>); + + @override + void subscribeToOrders() => super.noSuchMethod( + Invocation.method( + #subscribeToOrders, + [], + ), + returnValueForMissingStub: null, + ); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); + + @override + _i5.Future<_i3.NostrEvent?> getOrderById(String? orderId) => + (super.noSuchMethod( + Invocation.method( + #getOrderById, + [orderId], + ), + returnValue: _i5.Future<_i3.NostrEvent?>.value(), + ) as _i5.Future<_i3.NostrEvent?>); + + @override + _i5.Future addOrder(_i3.NostrEvent? order) => (super.noSuchMethod( + Invocation.method( + #addOrder, + [order], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future deleteOrder(String? orderId) => (super.noSuchMethod( + Invocation.method( + #deleteOrder, + [orderId], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future> getAllOrders() => (super.noSuchMethod( + Invocation.method( + #getAllOrders, + [], + ), + returnValue: _i5.Future>.value(<_i3.NostrEvent>[]), + ) as _i5.Future>); + + @override + _i5.Future updateOrder(_i3.NostrEvent? order) => (super.noSuchMethod( + Invocation.method( + #updateOrder, + [order], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); +} diff --git a/test/notifiers/add_order_notifier_test.dart b/test/notifiers/add_order_notifier_test.dart new file mode 100644 index 00000000..b693b5bc --- /dev/null +++ b/test/notifiers/add_order_notifier_test.dart @@ -0,0 +1,298 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.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/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/features/add_order/providers/add_order_notifier_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; + +import '../mocks.mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AddOrderNotifier - Mockito tests', () { + late ProviderContainer container; + late MockMostroRepository mockRepository; + late MockOpenOrdersRepository mockOrdersRepository; + + const testUuid = "test_uuid"; + + setUp(() { + container = ProviderContainer(); + mockRepository = MockMostroRepository(); + mockOrdersRepository = MockOpenOrdersRepository(); + }); + + tearDown(() { + container.dispose(); + }); + + /// Helper that sets up the mock repository so that when `publishOrder` is + /// called, it returns a Stream based on `confirmationJson`. + void configureMockPublishOrder(Map confirmationJson) { + final confirmationMessage = MostroMessage.fromJson(confirmationJson); + when(mockRepository.publishOrder(any)).thenAnswer((invocation) async { + // Return a stream that emits the confirmation message once. + return Stream.value(confirmationMessage); + }); + } + + test('New Sell Order (Fixed)', () async { + // This JSON simulates the confirmation message from Mostro for a new sell order. + final confirmationJsonSell = { + "order": { + "version": 1, + "id": "order_id_sell", + "action": "new-order", + "payload": { + "order": { + "id": "order_id_sell", + "kind": "sell", + "status": "pending", + "amount": 0, + "fiat_code": "VES", + "min_amount": null, + "max_amount": null, + "fiat_amount": 100, + "payment_method": "face to face", + "premium": 1, + "created_at": 0 + } + } + } + }; + configureMockPublishOrder(confirmationJsonSell); + + // Override the repository provider with our mock. + container = ProviderContainer(overrides: [ + mostroRepositoryProvider.overrideWithValue(mockRepository), + orderRepositoryProvider.overrideWithValue(mockOrdersRepository), + ]); + + // Create a new sell (fixed) order. + final newSellOrder = Order( + kind: OrderType.sell, + status: Status.pending, + amount: 0, + fiatCode: 'VES', + fiatAmount: 100, + paymentMethod: 'face to face', + premium: 1, + ); + + final notifier = + container.read(addOrderNotifierProvider(testUuid).notifier); + + // Submit the order + await notifier.submitOrder(newSellOrder); + + // Retrieve the final state + final state = container.read(addOrderNotifierProvider(testUuid)); + expect(state, isNotNull); + + final confirmedOrder = state.getPayload(); + expect(confirmedOrder, isNotNull); + expect(confirmedOrder!.kind, equals(OrderType.sell)); + expect(confirmedOrder.status.value, equals('pending')); + expect(confirmedOrder.amount, equals(0)); + expect(confirmedOrder.fiatCode, equals('VES')); + expect(confirmedOrder.fiatAmount, equals(100)); + expect(confirmedOrder.paymentMethod, equals('face to face')); + expect(confirmedOrder.premium, equals(1)); + expect(confirmedOrder.createdAt, equals(0)); + + // Optionally verify that publishOrder was called exactly once. + verify(mockRepository.publishOrder(any)).called(1); + }); + + test('New Sell Range Order', () async { + final confirmationJsonSellRange = { + "order": { + "version": 1, + "id": "order_id_sell_range", + "action": "new-order", + "payload": { + "order": { + "id": "order_id_sell_range", + "kind": "sell", + "status": "pending", + "amount": 0, + "fiat_code": "VES", + "min_amount": 10, + "max_amount": 20, + "fiat_amount": 0, + "payment_method": "face to face", + "premium": 1, + "created_at": 0 + } + } + } + }; + configureMockPublishOrder(confirmationJsonSellRange); + + container = ProviderContainer(overrides: [ + mostroRepositoryProvider.overrideWithValue(mockRepository), + orderRepositoryProvider.overrideWithValue(mockOrdersRepository), + ]); + + final newSellRangeOrder = Order( + kind: OrderType.sell, + status: Status.pending, + amount: 0, + fiatCode: 'VES', + minAmount: 10, + maxAmount: 20, + fiatAmount: 0, + paymentMethod: 'face to face', + premium: 1, + ); + + final notifier = + container.read(addOrderNotifierProvider(testUuid).notifier); + await notifier.submitOrder(newSellRangeOrder); + + final state = container.read(addOrderNotifierProvider(testUuid)); + expect(state, isNotNull); + + final confirmedOrder = state.getPayload(); + expect(confirmedOrder, isNotNull); + expect(confirmedOrder!.kind, equals(OrderType.sell)); + expect(confirmedOrder.status.value, equals('pending')); + expect(confirmedOrder.amount, equals(0)); + expect(confirmedOrder.minAmount, equals(10)); + expect(confirmedOrder.maxAmount, equals(20)); + expect(confirmedOrder.fiatAmount, equals(0)); + expect(confirmedOrder.paymentMethod, equals('face to face')); + expect(confirmedOrder.premium, equals(1)); + + verify(mockRepository.publishOrder(any)).called(1); + }); + + test('New Buy Order', () async { + final confirmationJsonBuy = { + "order": { + "version": 1, + "id": "order_id_buy", + "action": "new-order", + "payload": { + "order": { + "id": "order_id_buy", + "kind": "buy", + "status": "pending", + "amount": 0, + "fiat_code": "VES", + "fiat_amount": 100, + "payment_method": "face to face", + "premium": 1, + "master_buyer_pubkey": null, + "master_seller_pubkey": null, + "buyer_invoice": null, + "created_at": 0 + } + } + } + }; + configureMockPublishOrder(confirmationJsonBuy); + + container = ProviderContainer(overrides: [ + mostroRepositoryProvider.overrideWithValue(mockRepository), + orderRepositoryProvider.overrideWithValue(mockOrdersRepository), + ]); + + final newBuyOrder = Order( + kind: OrderType.buy, + status: Status.pending, + amount: 0, + fiatCode: 'VES', + fiatAmount: 100, + paymentMethod: 'face to face', + premium: 1, + ); + + final notifier = + container.read(addOrderNotifierProvider(testUuid).notifier); + await notifier.submitOrder(newBuyOrder); + + final state = container.read(addOrderNotifierProvider(testUuid)); + expect(state, isNotNull); + + final confirmedOrder = state.getPayload(); + expect(confirmedOrder, isNotNull); + expect(confirmedOrder!.kind, equals(OrderType.buy)); + expect(confirmedOrder.status.value, equals('pending')); + expect(confirmedOrder.fiatCode, equals('VES')); + expect(confirmedOrder.fiatAmount, equals(100)); + expect(confirmedOrder.paymentMethod, equals('face to face')); + expect(confirmedOrder.premium, equals(1)); + expect(confirmedOrder.buyerInvoice, isNull); + + verify(mockRepository.publishOrder(any)).called(1); + }); + + test('New Buy Order with Lightning Address', () async { + final confirmationJsonBuyInvoice = { + "order": { + "version": 1, + "id": "order_id_buy_invoice", + "action": "new-order", + "payload": { + "order": { + "id": "order_id_buy_invoice", + "kind": "buy", + "status": "pending", + "amount": 0, + "fiat_code": "VES", + "fiat_amount": 100, + "payment_method": "face to face", + "premium": 1, + "master_buyer_pubkey": null, + "master_seller_pubkey": null, + "buyer_invoice": "mostro_p2p@ln.tips", + "created_at": 0 + } + } + } + }; + configureMockPublishOrder(confirmationJsonBuyInvoice); + + container = ProviderContainer(overrides: [ + mostroRepositoryProvider.overrideWithValue(mockRepository), + orderRepositoryProvider.overrideWithValue(mockOrdersRepository), + ]); + + final newBuyOrderWithInvoice = Order( + kind: OrderType.buy, + status: Status.pending, + amount: 0, + fiatCode: 'VES', + fiatAmount: 100, + paymentMethod: 'face to face', + premium: 1, + buyerInvoice: 'mostro_p2p@ln.tips', + ); + + final notifier = + container.read(addOrderNotifierProvider(testUuid).notifier); + await notifier.submitOrder(newBuyOrderWithInvoice); + + final state = container.read(addOrderNotifierProvider(testUuid)); + expect(state, isNotNull); + + final confirmedOrder = state.getPayload(); + expect(confirmedOrder, isNotNull); + expect(confirmedOrder!.kind, equals(OrderType.buy)); + expect(confirmedOrder.status.value, equals('pending')); + expect(confirmedOrder.fiatAmount, equals(100)); + expect(confirmedOrder.paymentMethod, equals('face to face')); + expect(confirmedOrder.premium, equals(1)); + expect(confirmedOrder.buyerInvoice, equals('mostro_p2p@ln.tips')); + + verify(mockRepository.publishOrder(any)).called(1); + }); + }); +} diff --git a/test/notifiers/take_order_notifier_test.dart b/test/notifiers/take_order_notifier_test.dart new file mode 100644 index 00000000..c18c3b95 --- /dev/null +++ b/test/notifiers/take_order_notifier_test.dart @@ -0,0 +1,237 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/features/take_order/providers/order_notifier_providers.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; + +import '../mocks.mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Take Order Notifiers - Mockito tests', () { + late ProviderContainer container; + late MockMostroRepository mockRepository; + const testOrderId = "test_order_id"; + + setUp(() { + // Create a new instance of the mock repository. + mockRepository = MockMostroRepository(); + + }); + + tearDown(() { + container.dispose(); + }); + + /// Helper that stubs the repository method (for both takeBuyOrder and takeSellOrder) + /// so that it returns a Stream emitting the provided confirmation JSON. + void configureMockMethod( + Future> Function(String, dynamic, [dynamic]) + repositoryMethod, + Map confirmationJson, + ) { + final confirmationMessage = MostroMessage.fromJson(confirmationJson); + when(repositoryMethod(testOrderId, any, any)) + .thenAnswer((_) async => Stream.value(confirmationMessage)); + // For the takeBuyOrder method which takes only two parameters: + when(repositoryMethod(testOrderId, any, null)) + .thenAnswer((_) async => Stream.value(confirmationMessage)); + } + + test('Taking a Buy Order - seller sends take-buy and receives pay-invoice confirmation', () async { + // Confirmation JSON for "Taking a buy order": + final confirmationJsonTakeBuy = { + "order": { + "version": 1, + "id": testOrderId, + "action": "pay-invoice", + "payload": { + "payment_request": [ + { + "id": testOrderId, + "kind": "buy", + "status": "waiting-payment", + "amount": 7851, + "fiat_code": "VES", + "fiat_amount": 100, + "payment_method": "face to face", + "premium": 1, + "created_at": 1698957793 + }, + "ln_invoice_sample" + ] + } + } + }; + + // Stub the repository’s takeBuyOrder method. + when(mockRepository.takeBuyOrder(any, any)).thenAnswer((_) async { + final msg = MostroMessage.fromJson(confirmationJsonTakeBuy); + return Stream.value(msg); + }); + + // Override the repository provider with our mock. + container = ProviderContainer(overrides: [ + mostroRepositoryProvider.overrideWithValue(mockRepository), + ]); + + // Retrieve the notifier from the provider. + final takeBuyNotifier = + container.read(takeBuyOrderNotifierProvider(testOrderId).notifier); + + // Invoke the method to simulate taking a buy order. + await takeBuyNotifier.takeBuyOrder(testOrderId, 0); + + // Check that the state has been updated as expected. + final state = container.read(takeBuyOrderNotifierProvider(testOrderId)); + expect(state, isNotNull); + // We expect the confirmation action to be "pay-invoice". + expect(state.action, equals(Action.payInvoice)); + // Optionally verify that the repository method was called. + verify(mockRepository.takeBuyOrder(testOrderId, any)).called(1); + }); + + test('Taking a Sell Order (fixed) - buyer sends take-sell and receives add-invoice confirmation', () async { + final confirmationJsonTakeSell = { + "order": { + "version": 1, + "id": testOrderId, + "action": "add-invoice", + "payload": { + "order": { + "id": testOrderId, + "kind": "sell", + "status": "pending", + "amount": 0, + "fiat_code": "VES", + "fiat_amount": 100, + "payment_method": "face to face", + "premium": 1, + "created_at": 1698957793 + } + } + } + }; + + when(mockRepository.takeSellOrder(any, any, any)).thenAnswer((_) async { + final msg = MostroMessage.fromJson(confirmationJsonTakeSell); + return Stream.value(msg); + }); + + // Override the repository provider with our mock. + container = ProviderContainer(overrides: [ + mostroRepositoryProvider.overrideWithValue(mockRepository), + ]); + + final takeSellNotifier = + container.read(takeSellOrderNotifierProvider(testOrderId).notifier); + + // Simulate taking a sell order (with amount 0). + await takeSellNotifier.takeSellOrder(testOrderId, 0, null); + + final state = container.read(takeSellOrderNotifierProvider(testOrderId)); + expect(state, isNotNull); + expect(state.action, equals(Action.addInvoice)); + final orderPayload = state.getPayload(); + expect(orderPayload, isNotNull); + expect(orderPayload!.amount, equals(0)); + expect(orderPayload.fiatCode, equals('VES')); + expect(orderPayload.fiatAmount, equals(100)); + expect(orderPayload.paymentMethod, equals('face to face')); + expect(orderPayload.premium, equals(1)); + + verify(mockRepository.takeSellOrder(testOrderId, any, any)).called(1); + }); + + test('Taking a Sell Range Order - buyer sends take-sell with range payload', () async { + final confirmationJsonSellRange = { + "order": { + "version": 1, + "id": testOrderId, + "action": "add-invoice", + "payload": { + "order": { + "id": testOrderId, + "kind": "sell", + "status": "pending", + "amount": 0, + "fiat_code": "VES", + "min_amount": 10, + "max_amount": 20, + "fiat_amount": 15, + "payment_method": "face to face", + "premium": 1, + "created_at": 1698957793 + } + } + } + }; + + when(mockRepository.takeSellOrder(any, any, any)).thenAnswer((_) async { + final msg = MostroMessage.fromJson(confirmationJsonSellRange); + return Stream.value(msg); + }); + + // Override the repository provider with our mock. + container = ProviderContainer(overrides: [ + mostroRepositoryProvider.overrideWithValue(mockRepository), + ]); + + final takeSellNotifier = + container.read(takeSellOrderNotifierProvider(testOrderId).notifier); + + // Simulate taking a sell order with a fiat range (here amount is irrelevant because the payload carries range info). + await takeSellNotifier.takeSellOrder(testOrderId, 0, null); + + final state = container.read(takeSellOrderNotifierProvider(testOrderId)); + expect(state, isNotNull); + expect(state.action, equals(Action.addInvoice)); + final orderPayload = state.getPayload(); + expect(orderPayload, isNotNull); + expect(orderPayload!.minAmount, equals(10)); + expect(orderPayload.maxAmount, equals(20)); + expect(orderPayload.fiatAmount, equals(15)); + + verify(mockRepository.takeSellOrder(testOrderId, any, any)).called(1); + }); + + test('Taking a Sell Order with Lightning Address - buyer sends take-sell with LN address', () async { + final confirmationJsonSellLN = { + "order": { + "version": 1, + "id": testOrderId, + "action": "waiting-seller-to-pay", + "payload": null + } + }; + + when(mockRepository.takeSellOrder(any, any, any)).thenAnswer((_) async { + final msg = MostroMessage.fromJson(confirmationJsonSellLN); + return Stream.value(msg); + }); + + // Override the repository provider with our mock. + container = ProviderContainer(overrides: [ + mostroRepositoryProvider.overrideWithValue(mockRepository), + ]); + + final takeSellNotifier = + container.read(takeSellOrderNotifierProvider(testOrderId).notifier); + + // Simulate taking a sell order with a lightning address payload. + await takeSellNotifier.takeSellOrder(testOrderId, 0, "mostro_p2p@ln.tips"); + + final state = container.read(takeSellOrderNotifierProvider(testOrderId)); + expect(state, isNotNull); + expect(state.action, equals(Action.waitingSellerToPay)); + + verify(mockRepository.takeSellOrder(testOrderId, any, any)).called(1); + }); + + }); +} diff --git a/test/services/mostro_service_test.dart b/test/services/mostro_service_test.dart index 0755f57e..353bcfc2 100644 --- a/test/services/mostro_service_test.dart +++ b/test/services/mostro_service_test.dart @@ -10,7 +10,6 @@ import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; -import 'package:crypto/crypto.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; import 'mostro_service_test.mocks.dart'; @@ -84,7 +83,7 @@ void main() { when(mockSessionManager.getSessionByOrderId(orderId)).thenReturn(session); // Mock NostrService's createRumor, createSeal, createWrap, publishEvent - when(mockNostrService.createRumor(any, any, any)) + when(mockNostrService.createRumor(any, any, any, any)) .thenAnswer((_) async => 'encryptedRumorContent'); when(mockNostrService.generateKeyPair()) @@ -163,7 +162,7 @@ void main() { when(mockSessionManager.getSessionByOrderId(orderId)).thenReturn(session); // Mock NostrService's createRumor, createSeal, createWrap, publishEvent - when(mockNostrService.createRumor(any, any, any)) + when(mockNostrService.createRumor(any, any, any, any)) .thenAnswer((_) async => 'encryptedRumorContentInvalid'); when(mockNostrService.generateKeyPair()) @@ -241,7 +240,7 @@ void main() { mockServerTradeIndex.userTradeIndices[userPubKey] = 3; // Mock NostrService's createRumor, createSeal, createWrap, publishEvent - when(mockNostrService.createRumor(any, any, any)) + when(mockNostrService.createRumor(any, any, any, any)) .thenAnswer((_) async => 'encryptedRumorContentReused'); when(mockNostrService.generateKeyPair()) @@ -317,7 +316,7 @@ void main() { when(mockSessionManager.getSessionByOrderId(orderId)).thenReturn(session); // Mock NostrService's createRumor, createSeal, createWrap, publishEvent - when(mockNostrService.createRumor(any, any, any)) + when(mockNostrService.createRumor(any, any, any, any)) .thenAnswer((_) async => 'encryptedRumorContentFullPrivacy'); when(mockNostrService.generateKeyPair()) diff --git a/test/services/mostro_service_test.mocks.dart b/test/services/mostro_service_test.mocks.dart index 279d05e8..274277ca 100644 --- a/test/services/mostro_service_test.mocks.dart +++ b/test/services/mostro_service_test.mocks.dart @@ -1,5 +1,5 @@ // Mocks generated by Mockito 5.4.4 from annotations -// in mostro_mobile/test/services/mostro_service.dart. +// in mostro_mobile/test/services/mostro_service_test.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes @@ -210,17 +210,19 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { @override _i5.Future createRumor( - String? content, + _i2.NostrKeyPairs? senderKeyPair, + String? wrapperKey, String? recipientPubKey, - _i2.NostrKeyPairs? senderPrivateKey, + String? content, ) => (super.noSuchMethod( Invocation.method( #createRumor, [ - content, + senderKeyPair, + wrapperKey, recipientPubKey, - senderPrivateKey, + content, ], ), returnValue: _i5.Future.value(_i6.dummyValue( @@ -228,9 +230,10 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { Invocation.method( #createRumor, [ - content, + senderKeyPair, + wrapperKey, recipientPubKey, - senderPrivateKey, + content, ], ), )), From af60aa0a2159a4e0bbf20d988388e708639c1225 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sun, 9 Feb 2025 00:27:51 -0800 Subject: [PATCH 035/149] Integration tests for Create Sell Order --- integration_test/new_sell_order_test.dart | 35 +++++++++++-------- .../add_order/screens/add_order_screen.dart | 15 +++++--- .../screens/order_confirmation_screen.dart | 1 + 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/integration_test/new_sell_order_test.dart b/integration_test/new_sell_order_test.dart index 88db14af..548421d0 100644 --- a/integration_test/new_sell_order_test.dart +++ b/integration_test/new_sell_order_test.dart @@ -1,23 +1,16 @@ -// integration_test/add_order_screen_test.dart - import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:mostro_mobile/features/add_order/screens/order_confirmation_screen.dart'; import 'package:mostro_mobile/main.dart' as app; import 'package:flutter/material.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(() async { - app.main(); - // Wait for the app to settle after launch. - await Future.delayed(const Duration(seconds: 3)); - }); - group('Create New Sell Order', () { testWidgets('User creates a new SELL order with VES=100 and premium=1', (tester) async { - + app.main(); await tester.pumpAndSettle(); // Navigate to the “Add Order” screen @@ -98,7 +91,7 @@ void main() { expect(submitButton, findsOneWidget, reason: 'A SUBMIT button is expected'); await tester.tap(submitButton); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 3)); // The app sends a Nostr “Gift wrap” event with the following content: // { @@ -120,11 +113,19 @@ void main() { // } // We expect a confirmation => UI shows “pending” or success message + // Verify that the Order Confirmation screen is now displayed. + expect(find.byType(OrderConfirmationScreen), findsOneWidget); + + final homeButton = find.byKey(const Key('homeButton')); + expect(homeButton, findsOneWidget, reason: 'A home button is expected'); + await tester.tap(homeButton); + await tester.pumpAndSettle(); }); - testWidgets('User creates a new SELL range order with VES=10-20 and premium=1', + testWidgets( + 'User creates a new SELL range order with VES=10-20 and premium=1', (tester) async { - + app.main(); await tester.pumpAndSettle(); // Navigate to the “Add Order” screen @@ -206,6 +207,7 @@ void main() { reason: 'A SUBMIT button is expected'); await tester.tap(submitButton); await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 3)); // The app sends a Nostr “Gift wrap” event with the following content: // { @@ -229,8 +231,13 @@ void main() { // } // We expect a confirmation => UI shows “pending” or success message - // Verify success or "pending" - }); + // Verify that the Order Confirmation screen is now displayed. + expect(find.byType(OrderConfirmationScreen), findsOneWidget); + final homeButton = find.byKey(const Key('homeButton')); + expect(homeButton, findsOneWidget, reason: 'A home button is expected'); + await tester.tap(homeButton); + await tester.pumpAndSettle(); + }); }); } diff --git a/lib/features/add_order/screens/add_order_screen.dart b/lib/features/add_order/screens/add_order_screen.dart index 2c0eb746..f8505149 100644 --- a/lib/features/add_order/screens/add_order_screen.dart +++ b/lib/features/add_order/screens/add_order_screen.dart @@ -261,9 +261,15 @@ class _AddOrderScreenState extends ConsumerState { _buildDisabledWrapper( enabled: _isEnabled, child: CurrencyTextField( - key: const Key('fiatAmountField'), + key: const ValueKey('fiatAmountField'), controller: _fiatAmountController, label: 'Fiat amount', + onChanged: (parsed) { + setState(() { + _minFiatAmount = parsed.$1; + _maxFiatAmount = parsed.$2; + }); + }, ), ), @@ -310,6 +316,7 @@ class _AddOrderScreenState extends ConsumerState { 'Lightning Invoice or Lightning Address', const Key('lightningInvoiceField'), _lightningInvoiceController, + nullable: true, ), ), const SizedBox(height: 16), @@ -332,8 +339,8 @@ class _AddOrderScreenState extends ConsumerState { /// REUSABLE TEXT FIELD /// Widget _buildTextField( - String label, Key key, TextEditingController controller, - {IconData? suffix}) { + String label, Key key, TextEditingController controller, {bool nullable = false, + IconData? suffix}) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( @@ -351,7 +358,7 @@ class _AddOrderScreenState extends ConsumerState { suffixIcon: suffix != null ? Icon(suffix, color: AppTheme.grey2) : null, ), - validator: (value) { + validator: nullable ? null : (value) { if (value == null || value.isEmpty) { return 'Please enter a value'; } diff --git a/lib/features/add_order/screens/order_confirmation_screen.dart b/lib/features/add_order/screens/order_confirmation_screen.dart index a9622c87..51ab9ff0 100644 --- a/lib/features/add_order/screens/order_confirmation_screen.dart +++ b/lib/features/add_order/screens/order_confirmation_screen.dart @@ -31,6 +31,7 @@ class OrderConfirmationScreen extends ConsumerWidget { ), const SizedBox(height: 16), ElevatedButton( + key: const Key('homeButton'), onPressed: () => context.go('/'), child: const Text('Back to Home'), ), From 7d30f8bead4a9ceef3b4d247935f00787eb30d2b Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sun, 9 Feb 2025 15:07:53 -0800 Subject: [PATCH 036/149] Added Key Management and Relay screens --- assets/images/mostro-icons.png | Bin 49596 -> 52629 bytes integration_test/new_buy_order_test.dart | 473 ++++++++++++++++++ lib/app/app_routes.dart | 23 +- lib/app/app_theme.dart | 16 + .../add_order/screens/add_order_screen.dart | 1 + .../chat/screens/chat_list_screen.dart | 8 +- lib/features/home/screens/home_screen.dart | 31 +- .../key_manager/key_management_screen.dart | 210 ++++++++ .../mostro/screens/mostro_screen.dart | 87 ++++ .../order_book/screens/order_book_screen.dart | 8 +- lib/features/relays/relay.dart | 30 ++ lib/features/relays/relays_notifier.dart | 56 +++ lib/features/relays/relays_provider.dart | 9 + lib/features/relays/relays_screen.dart | 146 ++++++ lib/shared/widgets/bottom_nav_bar.dart | 4 +- ...ustom_app_bar.dart => mostro_app_bar.dart} | 4 +- lib/shared/widgets/mostro_app_drawer.dart | 53 ++ 17 files changed, 1118 insertions(+), 41 deletions(-) create mode 100644 integration_test/new_buy_order_test.dart create mode 100644 lib/features/key_manager/key_management_screen.dart create mode 100644 lib/features/mostro/screens/mostro_screen.dart create mode 100644 lib/features/relays/relay.dart create mode 100644 lib/features/relays/relays_notifier.dart create mode 100644 lib/features/relays/relays_provider.dart create mode 100644 lib/features/relays/relays_screen.dart rename lib/shared/widgets/{custom_app_bar.dart => mostro_app_bar.dart} (92%) create mode 100644 lib/shared/widgets/mostro_app_drawer.dart diff --git a/assets/images/mostro-icons.png b/assets/images/mostro-icons.png index f8c845f939b137e6b4839276afaee378c398c772..f01c5793e8831e16728da42aca99b236305492f1 100644 GIT binary patch literal 52629 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 z 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�%Ni z_Twit>Kj{*xYN_-STq8@m3g{XMknRC2dZhyR(oG3X490P6nW=o!HK}i(ra)kKa?v7L+;gamnfoE!JB&(-3arTj9xRR|cGibbi( zscaGgi}XBuhWpx`+9O%oIqlA$MpeVt8yzNsB8us3+!Wj|0LIl0Bi?8C@r2w!{VJw5 zoQg> z&K<`e<@;g%aeeIu46SgF0ucxw%O-0~#;R8jW!%f72yGSzt_>UhT-okKhup7DC{j8D zAAm}Cw=z3zD(*;UShQsp4s&y&Q@O>@PO>-cjXAa8HHM|tB*i==;wk(OJ(<*nR(xeHP=PR~*N#8hFs z=`>S&C$wRv(R4Lfkwz)6ZT@ML_HLW`ay~tYUH&W?@88NGW}>>uze9z%2WN(>)IJ(% z3_JOr>~V1DI4lM^t)~XT9+MSiS2LzRhi6~M6AA;*c}&k8m)Zo5-f~^`2iE!iF>d2W z;rfb*)hg!7ev-oRGEu!3E?JYl+c&KD^3+n5O-A!9X_9cuzw`roqtaUR`QOpWsN^5d z%xKsY#Ci1yMnwiiOs$|TEoJ+h$VxAbSOfLWw}|pi92>T=Ko#2sup?0Co2UUhM=nrhP z7(!>`_cSP#N~bq}YmZv?ikMiww##r?QKtg4* zZ+>2YHj$C7EOZ}zb#m^6syk;}w!Nr*abS?h01-gp`h+n70fM-Ct}V*+MD{gz%)51Q zX2#(+e&bMD(VOFy?qgN@T z>Gk(=3sfz$(gc^0&T^Mi9oDNJ1EO#E_D(T>q5cAJrZ10bqJ<&!dh6@QUZmlYimw<| zjc=L?I!vEEc27oc^6*UDXjqq%Pax8Y_ZE%VMx6B;7OV;uZJpwyG&q(B3I+)D^oV}4 zlq8*WQt<399fxH$G#zso-sI)wiL=;K{*lcvPfq85yWWj&BOst+)1r??UuIFqxHHSs5fe-=nTddDJ=?KWCO_t##Y)P&kheZ!wlknT+uWru=oFk zu_J)N`~v%hYK057gE9j~ZZO57ErtzxLPrKY<%pRKhUUnA@+AcoN)-YXB6%!%<8K8f`%U){1-}3Fr;2>qOGpT- fmq`#97%GrxE-+E@Opj_A4D=@{DkoAQr04%1334 'face to face' + final paymentMethodField = find.byKey(const Key('paymentMethodField')); + expect(paymentMethodField, findsOneWidget); + await tester.enterText(paymentMethodField, 'face to face'); + await tester.pumpAndSettle(); + + // Tap "SUBMIT" + final submitButton = find.text('SUBMIT'); + expect(submitButton, findsOneWidget, + reason: 'A SUBMIT button is expected'); + await tester.tap(submitButton); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + // The app sends a Nostr “Gift wrap” event with the following content: + // { + // "order": { + // "version": 1, + // "action": "new-order", + // "payload": { + // "order": { + // "kind": "buy", + // "amount": 0, + // "fiat_code": "VES", + // "fiat_amount": 100, + // "payment_method": "face to face", + // "premium": 1, + // "status": "pending", + // ... + // } + // } + // } + // } + + // Verify that the Order Confirmation screen is now displayed. + expect(find.byType(OrderConfirmationScreen), findsOneWidget); + + final homeButton = find.byKey(const Key('homeButton')); + expect(homeButton, findsOneWidget, reason: 'A home button is expected'); + await tester.tap(homeButton); + await tester.pumpAndSettle(); + }); + + + testWidgets( + 'User creates a new BUY order with EUR=10 and SATS=1500 using a lightning invoice', + (tester) async { + app.main(); + await tester.pumpAndSettle(); + + // Navigate to the “Add Order” screen + final createOrderButton = find.byKey(const Key('createOrderButton')); + expect(createOrderButton, findsOneWidget, + reason: 'We expect a button that navigates to AddOrderScreen'); + await tester.tap(createOrderButton); + await tester.pumpAndSettle(); + + // Fill out the “NEW ORDER” form + + // Select "BUY" tab + final sellTabFinder = find.text('BUY'); + if (sellTabFinder.evaluate().isNotEmpty) { + await tester.tap(sellTabFinder); + await tester.pumpAndSettle(); + } + + // Tap the fiat code dropdown, select 'EUR' + final fiatCodeDropdown = find.byKey(const Key('fiatCodeDropdown')); + expect(fiatCodeDropdown, findsOneWidget, + reason: 'Fiat code dropdown must exist with key(fiatCodeDropdown)'); + await tester.tap(fiatCodeDropdown); + await tester.pump(const Duration(seconds: 1)); + + // Choose 'EUR' from the dropdown + final optionFinder = find.byKey(Key('currency_EUR')); + final scrollableFinder = find.byType(Scrollable).last; + + await tester.scrollUntilVisible(optionFinder, 500.0, + scrollable: scrollableFinder); + await tester.pumpAndSettle(); + + expect(optionFinder, findsOneWidget); + await tester.tap(optionFinder); + await tester.pumpAndSettle(); + + expect(find.textContaining('EUR'), findsWidgets, + reason: + 'The CurrencyDropdown should now show EUR as the selected currency.'); + + // Enter fiat amount '10' + final fiatAmountField = find.byKey(const Key('fiatAmountField')); + expect(fiatAmountField, findsOneWidget); + await tester.enterText(fiatAmountField, '10'); + await tester.pumpAndSettle(); + + // Enter sats amount '15000' + final satsAmountField = find.byKey(const Key('satsAmountField')); + expect(satsAmountField, findsOneWidget); + await tester.enterText(satsAmountField, '15000'); + await tester.pumpAndSettle(); + + // LN Address => 'a Lightning Inboice for 15000 sats' + final lnAddressField = find.byKey(const Key('lightningInvoiceField')); + expect(lnAddressField, findsOneWidget); + await tester.enterText(lnAddressField, ''); + await tester.pumpAndSettle(); + + // Payment method => 'face to face' + final paymentMethodField = find.byKey(const Key('paymentMethodField')); + expect(paymentMethodField, findsOneWidget); + await tester.enterText(paymentMethodField, 'face to face'); + await tester.pumpAndSettle(); + + // Tap "SUBMIT" + final submitButton = find.text('SUBMIT'); + expect(submitButton, findsOneWidget, + reason: 'A SUBMIT button is expected'); + await tester.tap(submitButton); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + // The app sends a Nostr “Gift wrap” event with the following content: + // { + // "order": { + // "version": 1, + // "action": "new-order", + // "payload": { + // "order": { + // "kind": "sell", + // "amount": 15000, + // "fiat_code": "EUR", + // "fiat_amount": 10, + // "payment_method": "face to face", + // "premium": 0, + // "buyer_invoice": , + // "status": "pending", + // ... + // } + // } + // } + // } + + // Verify that the Order Confirmation screen is now displayed. + expect(find.byType(OrderConfirmationScreen), findsOneWidget); + + final homeButton = find.byKey(const Key('homeButton')); + expect(homeButton, findsOneWidget, reason: 'A home button is expected'); + await tester.tap(homeButton); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'User creates a new BUY order with EUR=10 at Market rate using a lightning invoice with no amount', + (tester) async { + app.main(); + await tester.pumpAndSettle(); + + // Navigate to the “Add Order” screen + final createOrderButton = find.byKey(const Key('createOrderButton')); + expect(createOrderButton, findsOneWidget, + reason: 'We expect a button that navigates to AddOrderScreen'); + await tester.tap(createOrderButton); + await tester.pumpAndSettle(); + + // Fill out the “NEW ORDER” form + + // Select "BUY" tab + final sellTabFinder = find.text('BUY'); + if (sellTabFinder.evaluate().isNotEmpty) { + await tester.tap(sellTabFinder); + await tester.pumpAndSettle(); + } + + // Tap the fiat code dropdown, select 'EUR' + final fiatCodeDropdown = find.byKey(const Key('fiatCodeDropdown')); + expect(fiatCodeDropdown, findsOneWidget, + reason: 'Fiat code dropdown must exist with key(fiatCodeDropdown)'); + await tester.tap(fiatCodeDropdown); + await tester.pump(const Duration(seconds: 1)); + + // Choose 'EUR' from the dropdown + final optionFinder = find.byKey(Key('currency_EUR')); + final scrollableFinder = find.byType(Scrollable).last; + + await tester.scrollUntilVisible(optionFinder, 500.0, + scrollable: scrollableFinder); + await tester.pumpAndSettle(); + + expect(optionFinder, findsOneWidget); + await tester.tap(optionFinder); + await tester.pumpAndSettle(); + + expect(find.textContaining('EUR'), findsWidgets, + reason: + 'The CurrencyDropdown should now show EUR as the selected currency.'); + + // Enter fiat amount '10' + final fiatAmountField = find.byKey(const Key('fiatAmountField')); + expect(fiatAmountField, findsOneWidget); + await tester.enterText(fiatAmountField, '10'); + await tester.pumpAndSettle(); + + final fixedSwitch = find.byKey(const Key('fixedSwitch')); + expect(fixedSwitch, findsOneWidget, + reason: 'FixedSwitch widget must be present.'); + // Tap the switch to toggle to Market mode. + await tester.tap(fixedSwitch); + await tester.pumpAndSettle(); + + // Verify that the label next to the switch shows "Market". + expect(find.text('Market'), findsOneWidget, + reason: 'The switch label should update to "Market".'); + + // Verify that the premium slider is visible instead of the sats text field. + final premiumSlider = find.byKey(const Key('premiumSlider')); + expect(premiumSlider, findsOneWidget, + reason: 'The premium slider must be visible in Market mode.'); + + // LN Address => 'a Lightning Inboice with no amount' + final lnAddressField = find.byKey(const Key('lightningInvoiceField')); + expect(lnAddressField, findsOneWidget); + await tester.enterText(lnAddressField, ''); + await tester.pumpAndSettle(); + + // Payment method => 'face to face' + final paymentMethodField = find.byKey(const Key('paymentMethodField')); + expect(paymentMethodField, findsOneWidget); + await tester.enterText(paymentMethodField, 'face to face'); + await tester.pumpAndSettle(); + + // Tap "SUBMIT" + final submitButton = find.text('SUBMIT'); + expect(submitButton, findsOneWidget, + reason: 'A SUBMIT button is expected'); + await tester.tap(submitButton); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + // The app sends a Nostr “Gift wrap” event with the following content: + // { + // "order": { + // "version": 1, + // "action": "new-order", + // "payload": { + // "order": { + // "kind": "sell", + // "amount": 0, + // "fiat_code": "EUR", + // "fiat_amount": 10, + // "payment_method": "face to face", + // "premium": 0, + // "buyer_invoice": , + // "status": "pending", + // ... + // } + // } + // } + // } + + // Verify that the Order Confirmation screen is now displayed. + expect(find.byType(OrderConfirmationScreen), findsOneWidget); + + final homeButton = find.byKey(const Key('homeButton')); + expect(homeButton, findsOneWidget, reason: 'A home button is expected'); + await tester.tap(homeButton); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'User creates a new BUY order with VES=100 and premium=1 using a LN Address', + (tester) async { + app.main(); + await tester.pumpAndSettle(); + + // Navigate to the “Add Order” screen + final createOrderButton = find.byKey(const Key('createOrderButton')); + expect(createOrderButton, findsOneWidget, + reason: 'We expect a button that navigates to AddOrderScreen'); + await tester.tap(createOrderButton); + await tester.pumpAndSettle(); + + // Fill out the “NEW ORDER” form + + // Select "BUY" tab + final sellTabFinder = find.text('BUY'); + if (sellTabFinder.evaluate().isNotEmpty) { + await tester.tap(sellTabFinder); + await tester.pumpAndSettle(); + } + + // Tap the fiat code dropdown, select 'VES' + final fiatCodeDropdown = find.byKey(const Key('fiatCodeDropdown')); + expect(fiatCodeDropdown, findsOneWidget, + reason: 'Fiat code dropdown must exist with key(fiatCodeDropdown)'); + await tester.tap(fiatCodeDropdown); + await tester.pump(const Duration(seconds: 1)); + + // Choose 'VES' from the dropdown + final optionFinder = find.byKey(Key('currency_VES')); + final scrollableFinder = find.byType(Scrollable).last; + + await tester.scrollUntilVisible(optionFinder, 500.0, + scrollable: scrollableFinder); + await tester.pumpAndSettle(); + + expect(optionFinder, findsOneWidget); + await tester.tap(optionFinder); + await tester.pumpAndSettle(); + + expect(find.textContaining('VES'), findsWidgets, + reason: + 'The CurrencyDropdown should now show VES as the selected currency.'); + + // Enter fiat amount '100' + final fiatAmountField = find.byKey(const Key('fiatAmountField')); + expect(fiatAmountField, findsOneWidget); + await tester.enterText(fiatAmountField, '100'); + await tester.pumpAndSettle(); + + final fixedSwitch = find.byKey(const Key('fixedSwitch')); + expect(fixedSwitch, findsOneWidget, + reason: 'FixedSwitch widget must be present.'); + // Tap the switch to toggle to Market mode. + await tester.tap(fixedSwitch); + await tester.pumpAndSettle(); + + // Verify that the label next to the switch shows "Market". + expect(find.text('Market'), findsOneWidget, + reason: 'The switch label should update to "Market".'); + + // Verify that the premium slider is visible instead of the sats text field. + final premiumSlider = find.byKey(const Key('premiumSlider')); + expect(premiumSlider, findsOneWidget, + reason: 'The premium slider must be visible in Market mode.'); + + // Set the premium slider to 1%. + // Assuming the slider range is -10 to 10, with an initial value of 0. + // We simulate a horizontal drag. The exact offset may need adjusting based on your UI. + await tester.drag(premiumSlider, const Offset(50, 0)); + await tester.pumpAndSettle(); + + // LN Address => a working LN address + final lnAddressField = find.byKey(const Key('lightningInvoiceField')); + expect(lnAddressField, findsOneWidget); + await tester.enterText(lnAddressField, 'chebizarro@coinos.io'); + await tester.pumpAndSettle(); + + // Payment method => 'face to face' + final paymentMethodField = find.byKey(const Key('paymentMethodField')); + expect(paymentMethodField, findsOneWidget); + await tester.enterText(paymentMethodField, 'face to face'); + await tester.pumpAndSettle(); + + // Tap "SUBMIT" + final submitButton = find.text('SUBMIT'); + expect(submitButton, findsOneWidget, + reason: 'A SUBMIT button is expected'); + await tester.tap(submitButton); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 3)); + + // The app sends a Nostr “Gift wrap” event with the following content: + // { + // "order": { + // "version": 1, + // "action": "new-order", + // "payload": { + // "order": { + // "kind": "sell", + // "amount": 0, + // "fiat_code": "VES", + // "fiat_amount": 100, + // "payment_method": "face to face", + // "premium": 1, + // "buyer_invoice": "mostro_p2p@ln.tips", + // "status": "pending", + // ... + // } + // } + // } + // } + + // Verify that the Order Confirmation screen is now displayed. + expect(find.byType(OrderConfirmationScreen), findsOneWidget); + + final homeButton = find.byKey(const Key('homeButton')); + expect(homeButton, findsOneWidget, reason: 'A home button is expected'); + await tester.tap(homeButton); + await tester.pumpAndSettle(); + }); + }); +} diff --git a/lib/app/app_routes.dart b/lib/app/app_routes.dart index cfbb0a23..4d1d4377 100644 --- a/lib/app/app_routes.dart +++ b/lib/app/app_routes.dart @@ -6,7 +6,10 @@ import 'package:mostro_mobile/features/add_order/screens/order_confirmation_scre import 'package:mostro_mobile/features/auth/screens/welcome_screen.dart'; import 'package:mostro_mobile/features/chat/screens/chat_list_screen.dart'; import 'package:mostro_mobile/features/home/screens/home_screen.dart'; +import 'package:mostro_mobile/features/key_manager/key_management_screen.dart'; +import 'package:mostro_mobile/features/mostro/screens/mostro_screen.dart'; import 'package:mostro_mobile/features/order_book/screens/order_book_screen.dart'; +import 'package:mostro_mobile/features/relays/relays_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/add_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/pay_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/take_order_screen.dart'; @@ -26,14 +29,14 @@ final goRouter = GoRouter( ); }, routes: [ - GoRoute( - path: '/welcome', - builder: (context, state) => const WelcomeScreen(), - ), GoRoute( path: '/', builder: (context, state) => const HomeScreen(), ), + GoRoute( + path: '/welcome', + builder: (context, state) => const WelcomeScreen(), + ), GoRoute( path: '/order_book', builder: (context, state) => const OrderBookScreen(), @@ -46,6 +49,18 @@ final goRouter = GoRouter( path: '/register', builder: (context, state) => const RegisterScreen(), ), + GoRoute( + path: '/mostro', + builder: (context, state) => const MostroScreen(), + ), + GoRoute( + path: '/relays', + builder: (context, state) => const RelaysScreen(), + ), + GoRoute( + path: '/key_management', + builder: (context, state) => const KeyManagementScreen(), + ), GoRoute( path: '/add_order', builder: (context, state) => AddOrderScreen(), diff --git a/lib/app/app_theme.dart b/lib/app/app_theme.dart index a34656f1..cf7b5824 100644 --- a/lib/app/app_theme.dart +++ b/lib/app/app_theme.dart @@ -31,6 +31,22 @@ class AppTheme { backgroundColor: Colors.transparent, elevation: 0, ), + dialogTheme: DialogTheme( + backgroundColor: dark2, + titleTextStyle: GoogleFonts.robotoCondensed( + fontWeight: FontWeight.bold, + fontSize: 20.0, + color: cream1, + ), + contentTextStyle: GoogleFonts.robotoCondensed( + fontWeight: FontWeight.w400, + fontSize: 16.0, + color: grey, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), textTheme: _buildTextTheme(), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( diff --git a/lib/features/add_order/screens/add_order_screen.dart b/lib/features/add_order/screens/add_order_screen.dart index f8505149..05a9e7d4 100644 --- a/lib/features/add_order/screens/add_order_screen.dart +++ b/lib/features/add_order/screens/add_order_screen.dart @@ -247,6 +247,7 @@ class _AddOrderScreenState extends ConsumerState { // 1) Currency dropdown always enabled CurrencyDropdown( + key: const Key('fiatCodeDropdown'), label: 'Fiat code', onSelected: (String fiatCode) { setState(() { diff --git a/lib/features/chat/screens/chat_list_screen.dart b/lib/features/chat/screens/chat_list_screen.dart index 76c4c6ae..cea94259 100644 --- a/lib/features/chat/screens/chat_list_screen.dart +++ b/lib/features/chat/screens/chat_list_screen.dart @@ -5,7 +5,8 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:mostro_mobile/features/chat/notifiers/chat_list_state.dart'; import 'package:mostro_mobile/features/chat/providers/chat_list_provider.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; -import 'package:mostro_mobile/shared/widgets/custom_app_bar.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; class ChatListScreen extends ConsumerWidget { const ChatListScreen({super.key}); @@ -17,7 +18,8 @@ class ChatListScreen extends ConsumerWidget { return Scaffold( backgroundColor: const Color(0xFF1D212C), - appBar: const CustomAppBar(), + appBar: const MostroAppBar(), + drawer: const MostroAppDrawer(), body: Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), decoration: BoxDecoration( @@ -29,7 +31,7 @@ class ChatListScreen extends ConsumerWidget { Padding( padding: const EdgeInsets.all(16.0), child: Text( - 'Chats', + 'Chat', style: TextStyle( color: Colors.white, fontSize: 24, diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 647cbff7..f9f440ff 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -7,9 +7,10 @@ import 'package:mostro_mobile/features/home/notifiers/home_notifier.dart'; import 'package:mostro_mobile/features/home/providers/home_notifer_provider.dart'; import 'package:mostro_mobile/features/home/notifiers/home_state.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; -import 'package:mostro_mobile/shared/widgets/custom_app_bar.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; import 'package:mostro_mobile/features/home/widgets/order_filter.dart'; import 'package:mostro_mobile/features/home/widgets/order_list.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @@ -23,32 +24,8 @@ class HomeScreen extends ConsumerWidget { data: (homeState) { return Scaffold( backgroundColor: AppTheme.dark1, - appBar: const CustomAppBar(), - drawer: Drawer( - // Add a ListView to the drawer. This ensures the user can scroll - // through the options in the drawer if there isn't enough vertical - // space to fit everything. - child: ListView( - // Important: Remove any padding from the ListView. - padding: EdgeInsets.zero, - children: [ - ListTile( - title: const Text('Item 1'), - onTap: () { - // Update the state of the app. - // ... - }, - ), - ListTile( - title: const Text('Item 2'), - onTap: () { - // Update the state of the app. - // ... - }, - ), - ], - ), - ), + appBar: const MostroAppBar(), + drawer: const MostroAppDrawer(), body: RefreshIndicator( onRefresh: () async { await homeNotifier.refresh(); diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart new file mode 100644 index 00000000..8908bc38 --- /dev/null +++ b/lib/features/key_manager/key_management_screen.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; + +class KeyManagementScreen extends ConsumerStatefulWidget { + const KeyManagementScreen({super.key}); + + @override + ConsumerState createState() => _KeyManagementScreenState(); +} + +class _KeyManagementScreenState extends ConsumerState { + String? _masterKey; + String? _mnemonic; + int? _tradeKeyIndex; + bool _loading = false; + final TextEditingController _importController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadKeys(); + } + + Future _loadKeys() async { + setState(() { + _loading = true; + }); + try { + final keyManager = ref.read(keyManagerProvider); + final hasMaster = await keyManager.hasMasterKey(); + if (hasMaster) { + final masterKeyPairs = await keyManager.getMasterKey(); + _masterKey = masterKeyPairs.private; + _mnemonic = await keyManager.getMnemonic(); + _tradeKeyIndex = await keyManager.getCurrentKeyIndex(); + } else { + _masterKey = 'No master key found'; + _mnemonic = 'No mnemonic found'; + _tradeKeyIndex = 0; + } + } catch (e) { + _masterKey = 'Error: $e'; + _mnemonic = 'Error: $e'; + } finally { + setState(() { + _loading = false; + }); + } + } + + void _copyToClipboard(String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$label copied to clipboard')), + ); + } + + Future _generateNewMasterKey() async { + final keyManager = ref.read(keyManagerProvider); + await keyManager.generateAndStoreMasterKey(); + await _loadKeys(); + } + + Future _deleteKeys() async { + final keyManager = ref.read(keyManagerProvider); + // Assume the KeyManager or its storage has a method to clear keys: + //await keyManager.clearKeys(); + await _loadKeys(); + } + + Future _importKey() async { + final keyManager = ref.read(keyManagerProvider); + final importValue = _importController.text.trim(); + if (importValue.isNotEmpty) { + try { + // For demonstration, if the input contains spaces, we treat it as a mnemonic; + // otherwise, we treat it as a master key. + if (importValue.contains(' ')) { + //await keyManager.importMnemonic(importValue); + } else { + //await keyManager.importMasterKey(importValue); + } + await _loadKeys(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Key imported successfully')), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Import failed: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), + onPressed: () => context.go('/'), + ), + title: Text('KEY MANAGEMENT', + style: TextStyle( + color: AppTheme.cream1, + fontFamily: GoogleFonts.robotoCondensed().fontFamily, + ),), + ), + backgroundColor: AppTheme.dark1, + body: _loading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: AppTheme.mediumPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Master Key + const Text( + 'Master Key', + style: TextStyle(color: AppTheme.cream1, fontSize: 18), + ), + const SizedBox(height: 8), + SelectableText( + _masterKey ?? '', + style: const TextStyle(color: AppTheme.cream1), + ), + TextButton( + onPressed: _masterKey != null + ? () => _copyToClipboard(_masterKey!, 'Master Key') + : null, + child: const Text('Copy Master Key'), + ), + const Divider(color: AppTheme.grey2), + const SizedBox(height: 16), + // Mnemonic + const Text( + 'Mnemonic', + style: TextStyle(color: AppTheme.cream1, fontSize: 18), + ), + const SizedBox(height: 8), + SelectableText( + _mnemonic ?? '', + style: const TextStyle(color: AppTheme.cream1), + ), + TextButton( + onPressed: _mnemonic != null + ? () => _copyToClipboard(_mnemonic!, 'Mnemonic') + : null, + child: const Text('Copy Mnemonic'), + ), + const Divider(color: AppTheme.grey2), + const SizedBox(height: 16), + // Trade Key Index + Text( + 'Current Trade Key Index: ${_tradeKeyIndex ?? 'N/A'}', + style: const TextStyle(color: AppTheme.cream1, fontSize: 16), + ), + const SizedBox(height: 16), + // Buttons to generate and delete keys + Row( + children: [ + ElevatedButton( + onPressed: _generateNewMasterKey, + child: const Text('Generate New Master Key'), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: _deleteKeys, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.red1, + ), + child: const Text('Delete Keys'), + ), + ], + ), + const SizedBox(height: 16), + // Import Key + const Text( + 'Import Key or Mnemonic', + style: TextStyle(color: AppTheme.cream1, fontSize: 18), + ), + const SizedBox(height: 8), + TextField( + controller: _importController, + style: const TextStyle(color: AppTheme.cream1), + decoration: const InputDecoration( + labelText: 'Enter key or mnemonic', + labelStyle: TextStyle(color: AppTheme.grey2), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _importKey, + child: const Text('Import Key'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/mostro/screens/mostro_screen.dart b/lib/features/mostro/screens/mostro_screen.dart new file mode 100644 index 00000000..692761bb --- /dev/null +++ b/lib/features/mostro/screens/mostro_screen.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/features/order_book/notifiers/order_book_state.dart'; +import 'package:mostro_mobile/features/order_book/providers/order_book_notifier.dart'; +import 'package:mostro_mobile/features/order_book/widgets/order_book_list.dart'; +import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; + +class MostroScreen extends ConsumerWidget { + const MostroScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final orderBookStateAsync = ref.watch(orderBookNotifierProvider); + + return orderBookStateAsync.when( + data: (orderBookState) { + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: const MostroAppBar(), + drawer: const MostroAppDrawer(), + body: RefreshIndicator( + onRefresh: () async { + //await orderBookNotifier.refresh(); + }, + child: Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), + decoration: BoxDecoration( + color: AppTheme.dark2, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Mostro', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + fontFamily: GoogleFonts.robotoCondensed().fontFamily, + ), + ), + ), + Expanded( + child: _buildOrderList(orderBookState), + ), + const BottomNavBar(), + ], + ), + ), + ), + ); + }, + loading: () => const Scaffold( + backgroundColor: AppTheme.dark1, + body: Center(child: CircularProgressIndicator()), + ), + error: (error, stack) => Scaffold( + backgroundColor: AppTheme.dark1, + body: Center( + child: Text( + 'Error: $error', + style: const TextStyle(color: AppTheme.cream1), + ), + ), + ), + ); + } + + Widget _buildOrderList(OrderBookState orderBookState) { + if (orderBookState.orders.isEmpty) { + return const Center( + child: Text( + 'No orders available for this type', + style: TextStyle(color: Colors.white), + ), + ); + } + + return OrderBookList(orders: orderBookState.orders); + } +} diff --git a/lib/features/order_book/screens/order_book_screen.dart b/lib/features/order_book/screens/order_book_screen.dart index dc306ddf..85b3b0b9 100644 --- a/lib/features/order_book/screens/order_book_screen.dart +++ b/lib/features/order_book/screens/order_book_screen.dart @@ -6,7 +6,8 @@ import 'package:mostro_mobile/features/order_book/notifiers/order_book_state.dar import 'package:mostro_mobile/features/order_book/providers/order_book_notifier.dart'; import 'package:mostro_mobile/features/order_book/widgets/order_book_list.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; -import 'package:mostro_mobile/shared/widgets/custom_app_bar.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; class OrderBookScreen extends ConsumerWidget { const OrderBookScreen({super.key}); @@ -19,7 +20,8 @@ class OrderBookScreen extends ConsumerWidget { data: (orderBookState) { return Scaffold( backgroundColor: AppTheme.dark1, - appBar: const CustomAppBar(), + appBar: const MostroAppBar(), + drawer: const MostroAppDrawer(), body: RefreshIndicator( onRefresh: () async { //await orderBookNotifier.refresh(); @@ -35,7 +37,7 @@ class OrderBookScreen extends ConsumerWidget { Padding( padding: EdgeInsets.all(16.0), child: Text( - 'My Order Book', + 'Order Book', style: TextStyle( color: Colors.white, fontSize: 24, diff --git a/lib/features/relays/relay.dart b/lib/features/relays/relay.dart new file mode 100644 index 00000000..b118956f --- /dev/null +++ b/lib/features/relays/relay.dart @@ -0,0 +1,30 @@ +class Relay { + final String url; + bool isHealthy; + + Relay({ + required this.url, + this.isHealthy = false, + }); + + Relay copyWith({String? url, bool? isHealthy}) { + return Relay( + url: url ?? this.url, + isHealthy: isHealthy ?? this.isHealthy, + ); + } + + Map toJson() { + return { + 'url': url, + 'isHealthy': isHealthy, + }; + } + + factory Relay.fromJson(Map json) { + return Relay( + url: json['url'] as String, + isHealthy: json['isHealthy'] as bool? ?? false, + ); + } +} diff --git a/lib/features/relays/relays_notifier.dart b/lib/features/relays/relays_notifier.dart new file mode 100644 index 00000000..ff6b30c9 --- /dev/null +++ b/lib/features/relays/relays_notifier.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mostro_mobile/app/config.dart'; // Assumes Config.initialRelays exists. +import 'relay.dart'; + +class RelaysNotifier extends StateNotifier> { + final SharedPreferencesAsync sharedPreferences; + static const _storageKey = 'relays'; + + RelaysNotifier(this.sharedPreferences) : super([]) { + _loadRelays(); + } + + Future _loadRelays() async { + final saved = await sharedPreferences.getString(_storageKey); + if (saved != null) { + final List jsonList = json.decode(saved) as List; + state = jsonList.map((e) => Relay.fromJson(e as Map)).toList(); + } else { + // Use the initial relay list from Config (assumed to be List) + state = Config.nostrRelays + .map((url) => Relay(url: url, isHealthy: true)) + .toList(); + await _saveRelays(); + } + } + + Future _saveRelays() async { + final jsonString = json.encode(state.map((r) => r.toJson()).toList()); + await sharedPreferences.setString(_storageKey, jsonString); + } + + Future addRelay(Relay relay) async { + state = [...state, relay]; + await _saveRelays(); + } + + Future updateRelay(Relay updatedRelay) async { + state = state.map((r) => r.url == updatedRelay.url ? updatedRelay : r).toList(); + await _saveRelays(); + } + + Future removeRelay(String url) async { + state = state.where((r) => r.url != url).toList(); + await _saveRelays(); + } + + /// For now, this simply sets all relays to “healthy.” + /// In a real app, you’d ping each relay (or use some health endpoint) + /// and update its status accordingly. + Future refreshRelayHealth() async { + state = state.map((r) => r.copyWith(isHealthy: true)).toList(); + await _saveRelays(); + } +} diff --git a/lib/features/relays/relays_provider.dart b/lib/features/relays/relays_provider.dart new file mode 100644 index 00000000..94d9a052 --- /dev/null +++ b/lib/features/relays/relays_provider.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'relays_notifier.dart'; +import 'relay.dart'; + +final relaysProvider = StateNotifierProvider>((ref) { + final prefs = ref.watch(sharedPreferencesProvider); // Assume you have this provider defined. + return RelaysNotifier(prefs); +}); diff --git a/lib/features/relays/relays_screen.dart b/lib/features/relays/relays_screen.dart new file mode 100644 index 00000000..a830eebd --- /dev/null +++ b/lib/features/relays/relays_screen.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; +import 'relays_provider.dart'; +import 'relay.dart'; + +class RelaysScreen extends ConsumerWidget { + const RelaysScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final relays = ref.watch(relaysProvider); + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), + onPressed: () => context.go('/'), + ), + title: Text( + 'RELAYS', + style: TextStyle( + color: AppTheme.cream1, + fontFamily: GoogleFonts.robotoCondensed().fontFamily, + ), + ), + ), + backgroundColor: AppTheme.dark1, + body: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: relays.length, + itemBuilder: (context, index) { + final relay = relays[index]; + return Card( + color: AppTheme.dark2, + margin: const EdgeInsets.symmetric(vertical: 8), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + title: Text( + relay.url, + style: const TextStyle(color: Colors.white), + ), + leading: Icon( + Icons.circle, + color: relay.isHealthy ? Colors.green : Colors.red, + size: 16, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, color: Colors.white), + onPressed: () { + _showEditDialog(context, relay, ref); + }, + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.white), + onPressed: () { + ref.read(relaysProvider.notifier).removeRelay(relay.url); + }, + ), + ], + ), + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + backgroundColor: AppTheme.mostroGreen, + child: const Icon(Icons.add), + onPressed: () => _showAddDialog(context, ref), + ), + ); + } + + void _showAddDialog(BuildContext context, WidgetRef ref) { + final controller = TextEditingController(); + showDialog( + useRootNavigator: true, + context: context, + builder: (BuildContext dialogContext) => AlertDialog( + title: const Text('Add Relay'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Relay URL'), + ), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final url = controller.text.trim(); + if (url.isNotEmpty) { + final newRelay = Relay(url: url, isHealthy: true); + ref.read(relaysProvider.notifier).addRelay(newRelay); + } + context.pop(); + }, + child: const Text('Add'), + ), + ], + ), + ); + } + +void _showEditDialog(BuildContext context, Relay relay, WidgetRef ref) { + final controller = TextEditingController(text: relay.url); + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Edit Relay'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Relay URL'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final newUrl = controller.text.trim(); + if (newUrl.isNotEmpty && newUrl != relay.url) { + final updatedRelay = relay.copyWith(url: newUrl); + ref.read(relaysProvider.notifier).updateRelay(updatedRelay); + } + Navigator.pop(dialogContext); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); +} +} diff --git a/lib/shared/widgets/bottom_nav_bar.dart b/lib/shared/widgets/bottom_nav_bar.dart index b2457ba9..9b15df0e 100644 --- a/lib/shared/widgets/bottom_nav_bar.dart +++ b/lib/shared/widgets/bottom_nav_bar.dart @@ -61,7 +61,7 @@ class BottomNavBar extends StatelessWidget { case 2: return currentLocation == '/chat_list'; case 3: - return currentLocation == '/profile'; + return currentLocation == '/mostro'; default: return false; } @@ -80,7 +80,7 @@ class BottomNavBar extends StatelessWidget { nextRoute = '/chat_list'; break; case 3: - nextRoute = '/profile'; + nextRoute = '/mostro'; break; default: return; diff --git a/lib/shared/widgets/custom_app_bar.dart b/lib/shared/widgets/mostro_app_bar.dart similarity index 92% rename from lib/shared/widgets/custom_app_bar.dart rename to lib/shared/widgets/mostro_app_bar.dart index 112cc229..5d815119 100644 --- a/lib/shared/widgets/custom_app_bar.dart +++ b/lib/shared/widgets/mostro_app_bar.dart @@ -3,8 +3,8 @@ import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; -class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { - const CustomAppBar({super.key}); +class MostroAppBar extends StatelessWidget implements PreferredSizeWidget { + const MostroAppBar({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/shared/widgets/mostro_app_drawer.dart b/lib/shared/widgets/mostro_app_drawer.dart new file mode 100644 index 00000000..c254bfbf --- /dev/null +++ b/lib/shared/widgets/mostro_app_drawer.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; + +class MostroAppDrawer extends StatelessWidget { + const MostroAppDrawer({super.key}); + + @override + Widget build(BuildContext context) { + return Drawer( + backgroundColor: AppTheme.cream1, + child: ListView( + children: [ + DrawerHeader( + decoration: BoxDecoration( + color: AppTheme.dark2, + image: const DecorationImage( + image: AssetImage("assets/images/mostro-icons.png"), + fit: BoxFit.scaleDown)), + child: Stack( + children: [ + Positioned( + bottom: 8.0, + left: 4.0, + child: Text( + "Mostro", + style: TextStyle( + color: AppTheme.cream1, + fontFamily: GoogleFonts.robotoCondensed().fontFamily, + ), + ), + ) + ], + ), + ), + ListTile( + title: const Text('Relays'), + onTap: () { + context.go('/relays'); + }, + ), + ListTile( + title: const Text('Key Management'), + onTap: () { + context.go('/key_management'); + }, + ), + ], + ), + ); + } +} From 23a856bec83aea5e1ae24a9479421576044b6c8d Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sun, 9 Feb 2025 16:00:29 -0800 Subject: [PATCH 037/149] Added extra key manager methods for importing existing key --- lib/app/app_theme.dart | 12 +++++- .../key_manager/key_management_screen.dart | 37 +++++-------------- lib/features/key_manager/key_manager.dart | 12 +++++- lib/shared/widgets/mostro_app_drawer.dart | 4 +- 4 files changed, 32 insertions(+), 33 deletions(-) diff --git a/lib/app/app_theme.dart b/lib/app/app_theme.dart index cf7b5824..cf359e8f 100644 --- a/lib/app/app_theme.dart +++ b/lib/app/app_theme.dart @@ -16,15 +16,18 @@ class AppTheme { // Padding and Margin Constants static const EdgeInsets smallPadding = EdgeInsets.all(8.0); - static const EdgeInsets mediumPadding = EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0); + static const EdgeInsets mediumPadding = + EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0); static const EdgeInsets largePadding = EdgeInsets.all(24.0); static const EdgeInsets smallMargin = EdgeInsets.all(4.0); - static const EdgeInsets mediumMargin = EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0); + static const EdgeInsets mediumMargin = + EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0); static const EdgeInsets largeMargin = EdgeInsets.all(20.0); static ThemeData get theme { return ThemeData( + hoverColor: dark1, primaryColor: mostroGreen, scaffoldBackgroundColor: dark1, appBarTheme: const AppBarTheme( @@ -99,6 +102,11 @@ class AppTheme { color: cream1, size: 24.0, ), + listTileTheme: ListTileThemeData( + titleTextStyle: TextStyle( + color: grey, + fontFamily: GoogleFonts.robotoCondensed().fontFamily, + )), ); } diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index 8908bc38..a0cb19f9 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -11,7 +11,8 @@ class KeyManagementScreen extends ConsumerStatefulWidget { const KeyManagementScreen({super.key}); @override - ConsumerState createState() => _KeyManagementScreenState(); + ConsumerState createState() => + _KeyManagementScreenState(); } class _KeyManagementScreenState extends ConsumerState { @@ -67,25 +68,12 @@ class _KeyManagementScreenState extends ConsumerState { await _loadKeys(); } - Future _deleteKeys() async { - final keyManager = ref.read(keyManagerProvider); - // Assume the KeyManager or its storage has a method to clear keys: - //await keyManager.clearKeys(); - await _loadKeys(); - } - Future _importKey() async { final keyManager = ref.read(keyManagerProvider); final importValue = _importController.text.trim(); if (importValue.isNotEmpty) { try { - // For demonstration, if the input contains spaces, we treat it as a mnemonic; - // otherwise, we treat it as a master key. - if (importValue.contains(' ')) { - //await keyManager.importMnemonic(importValue); - } else { - //await keyManager.importMasterKey(importValue); - } + await keyManager.importMnemonic(importValue); await _loadKeys(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Key imported successfully')), @@ -108,11 +96,13 @@ class _KeyManagementScreenState extends ConsumerState { icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), onPressed: () => context.go('/'), ), - title: Text('KEY MANAGEMENT', + title: Text( + 'KEY MANAGEMENT', style: TextStyle( color: AppTheme.cream1, fontFamily: GoogleFonts.robotoCondensed().fontFamily, - ),), + ), + ), ), backgroundColor: AppTheme.dark1, body: _loading @@ -161,7 +151,8 @@ class _KeyManagementScreenState extends ConsumerState { // Trade Key Index Text( 'Current Trade Key Index: ${_tradeKeyIndex ?? 'N/A'}', - style: const TextStyle(color: AppTheme.cream1, fontSize: 16), + style: + const TextStyle(color: AppTheme.cream1, fontSize: 16), ), const SizedBox(height: 16), // Buttons to generate and delete keys @@ -171,20 +162,12 @@ class _KeyManagementScreenState extends ConsumerState { onPressed: _generateNewMasterKey, child: const Text('Generate New Master Key'), ), - const SizedBox(width: 16), - ElevatedButton( - onPressed: _deleteKeys, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - ), - child: const Text('Delete Keys'), - ), ], ), const SizedBox(height: 16), // Import Key const Text( - 'Import Key or Mnemonic', + 'Import Key from Mnemonic', style: TextStyle(color: AppTheme.cream1, fontSize: 18), ), const SizedBox(height: 8), diff --git a/lib/features/key_manager/key_manager.dart b/lib/features/key_manager/key_manager.dart index 285c2629..5e30d8d9 100644 --- a/lib/features/key_manager/key_manager.dart +++ b/lib/features/key_manager/key_manager.dart @@ -17,12 +17,20 @@ class KeyManager { /// Generate a new mnemonic, derive the master key, and store both Future generateAndStoreMasterKey() async { final mnemonic = _derivator.generateMnemonic(); + await generateAndStoreMasterKeyFromMnemonic(mnemonic); + } + + // Generate a new master key from the supplied mnemonic + Future generateAndStoreMasterKeyFromMnemonic(String mnemonic) async { final masterKeyHex = _derivator.extendedKeyFromMnemonic(mnemonic); await _storage.storeMnemonic(mnemonic); await _storage.storeMasterKey(masterKeyHex); - await _storage - .storeTradeKeyIndex(1); + await _storage.storeTradeKeyIndex(1); + } + + Future importMnemonic(String mnemonic) async { + await generateAndStoreMasterKeyFromMnemonic(mnemonic); } /// Retrieve the master key from storage, returning NostrKeyPairs diff --git a/lib/shared/widgets/mostro_app_drawer.dart b/lib/shared/widgets/mostro_app_drawer.dart index c254bfbf..181f7897 100644 --- a/lib/shared/widgets/mostro_app_drawer.dart +++ b/lib/shared/widgets/mostro_app_drawer.dart @@ -9,12 +9,12 @@ class MostroAppDrawer extends StatelessWidget { @override Widget build(BuildContext context) { return Drawer( - backgroundColor: AppTheme.cream1, + backgroundColor: AppTheme.dark2, child: ListView( children: [ DrawerHeader( decoration: BoxDecoration( - color: AppTheme.dark2, + color: AppTheme.dark1, image: const DecorationImage( image: AssetImage("assets/images/mostro-icons.png"), fit: BoxFit.scaleDown)), From ef838ab672d4fef56b2c821c2bd4a489afe3c98e Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sun, 9 Feb 2025 19:55:37 -0800 Subject: [PATCH 038/149] Added rating feature --- .../notfiers/abstract_order_notifier.dart | 10 +++ .../rate/rate_counterpart_screen.dart | 89 +++++++++++++++++++ lib/features/rate/star_rating.dart | 57 ++++++++++++ .../screens/add_lightning_invoice_screen.dart | 16 ++-- .../screens/pay_lightning_invoice_screen.dart | 12 +-- .../take_order/screens/take_order_screen.dart | 2 +- lib/services/mostro_service.dart | 4 +- 7 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 lib/features/rate/rate_counterpart_screen.dart create mode 100644 lib/features/rate/star_rating.dart diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 5553bb71..265e4be9 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; @@ -42,6 +43,14 @@ class AbstractOrderNotifier extends StateNotifier { final notifProvider = ref.read(notificationProvider.notifier); switch (state.action) { + case Action.addInvoice: + navProvider.go('/add_invoice/$orderId'); + break; + case Action.cantDo: + final cantDo = state.getPayload(); + notifProvider + .showInformation(state.action, values: {'action': cantDo?.cantDo}); + break; case Action.newOrder: navProvider.go('/order_confirmed/${state.id!}'); break; @@ -63,6 +72,7 @@ class AbstractOrderNotifier extends StateNotifier { }); break; case Action.waitingSellerToPay: + navProvider.go('/'); notifProvider.showInformation(state.action, values: {'id': state.id}); break; case Action.waitingBuyerInvoice: diff --git a/lib/features/rate/rate_counterpart_screen.dart b/lib/features/rate/rate_counterpart_screen.dart new file mode 100644 index 00000000..5fd36ee7 --- /dev/null +++ b/lib/features/rate/rate_counterpart_screen.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; +import 'star_rating.dart'; + +class RateCounterpartScreen extends StatefulWidget { + const RateCounterpartScreen({super.key}); + + @override + State createState() => _RateCounterpartScreenState(); +} + +class _RateCounterpartScreenState extends State { + int _rating = 0; + final _logger = Logger(); + + void _submitRating() { + // Here you would typically call a notifier/provider method to persist the rating. + // For now, we'll simply print it and navigate back. + _logger.i('Rating submitted: $_rating'); + // Optionally, show a confirmation SnackBar: + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Rating submitted!')), + ); + // Navigate back (or to a confirmation screen) using GoRouter. + context.pop(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: const Text('Rate Counterpart', + style: TextStyle(color: AppTheme.cream1)), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: AppTheme.cream1), + onPressed: () => context.pop(), + ), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'How would you rate your counterpart?', + style: TextStyle(color: AppTheme.cream1, fontSize: 20), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + StarRating( + initialRating: _rating, + onRatingChanged: (rating) { + setState(() { + _rating = rating; + }); + }, + ), + const SizedBox(height: 24), + Text( + '$_rating / 5', + style: const TextStyle(color: AppTheme.cream1, fontSize: 18), + ), + const SizedBox(height: 24), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + padding: + const EdgeInsets.symmetric(vertical: 16, horizontal: 32), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + onPressed: _rating > 0 ? _submitRating : null, + child: + const Text('Submit Rating', style: TextStyle(fontSize: 16)), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/rate/star_rating.dart b/lib/features/rate/star_rating.dart new file mode 100644 index 00000000..1f8ca3e9 --- /dev/null +++ b/lib/features/rate/star_rating.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; + +class StarRating extends StatefulWidget { + /// The initial rating (between 0 and 5). + final int initialRating; + + /// Called when the user selects a new rating. + final ValueChanged onRatingChanged; + + const StarRating({ + super.key, + this.initialRating = 0, + required this.onRatingChanged, + }); + + @override + State createState() => _StarRatingState(); +} + +class _StarRatingState extends State { + late int _currentRating; + + @override + void initState() { + super.initState(); + _currentRating = widget.initialRating; + } + + Widget _buildStar(int index) { + // Use a filled star if the index is less than current rating; otherwise an outlined star. + final icon = index < _currentRating ? Icons.star : Icons.star_border; + final color = index < _currentRating ? AppTheme.mostroGreen : AppTheme.grey2; + + return GestureDetector( + onTap: () { + setState(() { + _currentRating = index + 1; + }); + widget.onRatingChanged(_currentRating); + }, + child: Icon( + icon, + color: color, + size: 36, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(5, (index) => _buildStar(index)), + ); + } +} diff --git a/lib/features/take_order/screens/add_lightning_invoice_screen.dart b/lib/features/take_order/screens/add_lightning_invoice_screen.dart index 04b29730..c1829255 100644 --- a/lib/features/take_order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/add_lightning_invoice_screen.dart @@ -1,8 +1,10 @@ +import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/features/home/notifiers/home_notifier.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; @@ -21,14 +23,14 @@ class _AddLightningInvoiceScreenState extends ConsumerState { final TextEditingController invoiceController = TextEditingController(); - Future? _orderFuture; + Future? _orderFuture; @override void initState() { super.initState(); // Kick off async load from OrderRepository final orderRepo = ref.read(orderRepositoryProvider); - _orderFuture = orderRepo.getOrderById(widget.orderId) as Future?; + _orderFuture = orderRepo.getOrderById(widget.orderId); } @override @@ -36,7 +38,7 @@ class _AddLightningInvoiceScreenState return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar(title: 'Add Lightning Invoice'), - body: FutureBuilder( + body: FutureBuilder( future: _orderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { @@ -63,7 +65,7 @@ class _AddLightningInvoiceScreenState } // Now we have the order, we can safely reference order.amount, etc. - final amount = order.amount; + final amount = order.fiatAmount; return CustomCard( padding: const EdgeInsets.all(16), @@ -133,10 +135,6 @@ class _AddLightningInvoiceScreenState // or a specialized method orderRepo.sendInvoice // For this example, let's just do an "update" - final updated = order.copyWith( - buyerInvoice: invoice, - ); - await orderRepo.updateOrder(updated); // If you want to navigate away or confirm if (!mounted) return; diff --git a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart b/lib/features/take_order/screens/pay_lightning_invoice_screen.dart index 25ec8996..60ef2a7c 100644 --- a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/pay_lightning_invoice_screen.dart @@ -1,3 +1,4 @@ +import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -21,14 +22,14 @@ class PayLightningInvoiceScreen extends ConsumerStatefulWidget { class _PayLightningInvoiceScreenState extends ConsumerState { - Future? _orderFuture; + Future? _orderFuture; @override void initState() { super.initState(); // Kick off async load from the Encrypted DB final orderRepo = ref.read(orderRepositoryProvider); - _orderFuture = orderRepo.getOrderById(widget.orderId) as Future?; + _orderFuture = orderRepo.getOrderById(widget.orderId); } @override @@ -36,7 +37,7 @@ class _PayLightningInvoiceScreenState return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar(title: 'Pay Lightning Invoice'), - body: FutureBuilder( + body: FutureBuilder( future: _orderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { @@ -53,7 +54,7 @@ class _PayLightningInvoiceScreenState } else { final order = snapshot.data; // If the order isn't found or buyerInvoice is null/empty - if (order == null || order.buyerInvoice == null || order.buyerInvoice!.isEmpty) { + if (order == null) { return const Center( child: Text( 'Invalid payment request.', @@ -63,7 +64,8 @@ class _PayLightningInvoiceScreenState } // We have a valid LN invoice in order.buyerInvoice - final lnInvoice = order.buyerInvoice!; + final lnInvoice = ''; + // order.buyerInvoice!; return SingleChildScrollView( padding: const EdgeInsets.all(16), diff --git a/lib/features/take_order/screens/take_order_screen.dart b/lib/features/take_order/screens/take_order_screen.dart index 30c7c190..678a1a87 100644 --- a/lib/features/take_order/screens/take_order_screen.dart +++ b/lib/features/take_order/screens/take_order_screen.dart @@ -183,7 +183,7 @@ class TakeOrderScreen extends ConsumerWidget { } else { final lndAddress = _lndAddressController.text.trim(); await orderDetailsNotifier.takeSellOrder( - realOrderId, satsAmount, lndAddress); + realOrderId, satsAmount, lndAddress.isEmpty ? null : lndAddress); } // Could also pass the LN address if your method expects it }, style: ElevatedButton.styleFrom( diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 1c7c8948..170239b6 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -79,7 +79,6 @@ class MostroService { if (!session.fullPrivacy) { order.tradeIndex = session.keyIndex; final message = order.toJson(); - final serializedEvent = jsonEncode(message['order']); final bytes = utf8.encode(serializedEvent); final digest = sha256.convert(bytes); @@ -114,9 +113,8 @@ class MostroService { return { 'order': { 'version': Config.mostroVersion, - 'request_id': orderId, 'trade_index': null, - 'id': null, + 'id': orderId, 'action': actionType.value, 'payload': payload, }, From b4287c8980583147ef8c039380d62e80ae716f84 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sun, 9 Feb 2025 20:42:04 -0800 Subject: [PATCH 039/149] renamed order book and chat to trades and messages respectively --- lib/app/app_routes.dart | 8 +++--- .../chat/providers/chat_list_provider.dart | 15 ----------- .../notifiers/messages_detail_notifier.dart} | 12 ++++----- .../notifiers/messages_detail_state.dart} | 16 ++++++------ .../notifiers/messages_list_notifier.dart} | 12 ++++----- .../notifiers/messages_list_state.dart} | 16 ++++++------ .../providers/messages_list_provider.dart | 15 +++++++++++ .../screens/messages_detail_screen.dart} | 26 ++++++++++--------- .../screens/messages_list_screen.dart} | 22 ++++++++-------- .../mostro/screens/mostro_screen.dart | 12 ++++----- .../notifiers/order_book_notifier.dart | 15 ----------- .../providers/order_book_notifier.dart | 8 ------ .../take_order/screens/take_order_screen.dart | 5 ---- .../trades/notifiers/trades_notifier.dart | 13 ++++++++++ .../notifiers/trades_state.dart} | 4 +-- .../trades/providers/trades_notifier.dart | 8 ++++++ .../screens/trades_screen.dart} | 18 ++++++------- .../widgets/trades_list.dart} | 8 +++--- .../widgets/trades_list_item.dart} | 7 +++-- 19 files changed, 117 insertions(+), 123 deletions(-) delete mode 100644 lib/features/chat/providers/chat_list_provider.dart rename lib/features/{chat/notifiers/chat_detail_notifier.dart => messages/notifiers/messages_detail_notifier.dart} (67%) rename lib/features/{chat/notifiers/chat_detail_state.dart => messages/notifiers/messages_detail_state.dart} (58%) rename lib/features/{chat/notifiers/chat_list_notifier.dart => messages/notifiers/messages_list_notifier.dart} (69%) rename lib/features/{chat/notifiers/chat_list_state.dart => messages/notifiers/messages_list_state.dart} (59%) create mode 100644 lib/features/messages/providers/messages_list_provider.dart rename lib/features/{chat/screens/chat_detail_screen.dart => messages/screens/messages_detail_screen.dart} (79%) rename lib/features/{chat/screens/chat_list_screen.dart => messages/screens/messages_list_screen.dart} (88%) delete mode 100644 lib/features/order_book/notifiers/order_book_notifier.dart delete mode 100644 lib/features/order_book/providers/order_book_notifier.dart create mode 100644 lib/features/trades/notifiers/trades_notifier.dart rename lib/features/{order_book/notifiers/order_book_state.dart => trades/notifiers/trades_state.dart} (61%) create mode 100644 lib/features/trades/providers/trades_notifier.dart rename lib/features/{order_book/screens/order_book_screen.dart => trades/screens/trades_screen.dart} (81%) rename lib/features/{order_book/widgets/order_book_list.dart => trades/widgets/trades_list.dart} (54%) rename lib/features/{order_book/widgets/order_book_list_item.dart => trades/widgets/trades_list_item.dart} (97%) diff --git a/lib/app/app_routes.dart b/lib/app/app_routes.dart index 4d1d4377..7aa59477 100644 --- a/lib/app/app_routes.dart +++ b/lib/app/app_routes.dart @@ -4,11 +4,11 @@ import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/features/add_order/screens/add_order_screen.dart'; import 'package:mostro_mobile/features/add_order/screens/order_confirmation_screen.dart'; import 'package:mostro_mobile/features/auth/screens/welcome_screen.dart'; -import 'package:mostro_mobile/features/chat/screens/chat_list_screen.dart'; +import 'package:mostro_mobile/features/messages/screens/messages_list_screen.dart'; import 'package:mostro_mobile/features/home/screens/home_screen.dart'; import 'package:mostro_mobile/features/key_manager/key_management_screen.dart'; import 'package:mostro_mobile/features/mostro/screens/mostro_screen.dart'; -import 'package:mostro_mobile/features/order_book/screens/order_book_screen.dart'; +import 'package:mostro_mobile/features/trades/screens/trades_screen.dart'; import 'package:mostro_mobile/features/relays/relays_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/add_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/pay_lightning_invoice_screen.dart'; @@ -39,11 +39,11 @@ final goRouter = GoRouter( ), GoRoute( path: '/order_book', - builder: (context, state) => const OrderBookScreen(), + builder: (context, state) => const TradesScreen(), ), GoRoute( path: '/chat_list', - builder: (context, state) => const ChatListScreen(), + builder: (context, state) => const MessagesListScreen(), ), GoRoute( path: '/register', diff --git a/lib/features/chat/providers/chat_list_provider.dart b/lib/features/chat/providers/chat_list_provider.dart deleted file mode 100644 index 809a2cae..00000000 --- a/lib/features/chat/providers/chat_list_provider.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/chat/notifiers/chat_detail_notifier.dart'; -import 'package:mostro_mobile/features/chat/notifiers/chat_detail_state.dart'; -import 'package:mostro_mobile/features/chat/notifiers/chat_list_notifier.dart'; -import 'package:mostro_mobile/features/chat/notifiers/chat_list_state.dart'; - -final chatListNotifierProvider = - StateNotifierProvider( - (ref) => ChatListNotifier(), -); - -final chatDetailNotifierProvider = StateNotifierProvider.family< - ChatDetailNotifier, ChatDetailState, String>((ref, chatId) { - return ChatDetailNotifier(chatId); -}); \ No newline at end of file diff --git a/lib/features/chat/notifiers/chat_detail_notifier.dart b/lib/features/messages/notifiers/messages_detail_notifier.dart similarity index 67% rename from lib/features/chat/notifiers/chat_detail_notifier.dart rename to lib/features/messages/notifiers/messages_detail_notifier.dart index 855721db..e9155033 100644 --- a/lib/features/chat/notifiers/chat_detail_notifier.dart +++ b/lib/features/messages/notifiers/messages_detail_notifier.dart @@ -1,17 +1,17 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'chat_detail_state.dart'; +import 'messages_detail_state.dart'; -class ChatDetailNotifier extends StateNotifier { +class MessagesDetailNotifier extends StateNotifier { final String chatId; - ChatDetailNotifier(this.chatId) : super(const ChatDetailState()) { + MessagesDetailNotifier(this.chatId) : super(const MessagesDetailState()) { loadChatDetail(); } Future loadChatDetail() async { try { - state = state.copyWith(status: ChatDetailStatus.loading); + state = state.copyWith(status: MessagesDetailStatus.loading); // Simulate a delay / fetch from repo await Future.delayed(const Duration(seconds: 1)); @@ -19,13 +19,13 @@ class ChatDetailNotifier extends StateNotifier { final chatMessages = []; state = state.copyWith( - status: ChatDetailStatus.loaded, + status: MessagesDetailStatus.loaded, messages: chatMessages, error: null, ); } catch (e) { state = state.copyWith( - status: ChatDetailStatus.error, + status: MessagesDetailStatus.error, error: e.toString(), ); } diff --git a/lib/features/chat/notifiers/chat_detail_state.dart b/lib/features/messages/notifiers/messages_detail_state.dart similarity index 58% rename from lib/features/chat/notifiers/chat_detail_state.dart rename to lib/features/messages/notifiers/messages_detail_state.dart index 83a0e761..3e46cf7f 100644 --- a/lib/features/chat/notifiers/chat_detail_state.dart +++ b/lib/features/messages/notifiers/messages_detail_state.dart @@ -1,28 +1,28 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; -enum ChatDetailStatus { +enum MessagesDetailStatus { loading, loaded, error, } -class ChatDetailState { - final ChatDetailStatus status; +class MessagesDetailState { + final MessagesDetailStatus status; final List messages; final String? error; - const ChatDetailState({ - this.status = ChatDetailStatus.loading, + const MessagesDetailState({ + this.status = MessagesDetailStatus.loading, this.messages = const [], this.error, }); - ChatDetailState copyWith({ - ChatDetailStatus? status, + MessagesDetailState copyWith({ + MessagesDetailStatus? status, List? messages, String? error, }) { - return ChatDetailState( + return MessagesDetailState( status: status ?? this.status, messages: messages ?? this.messages, error: error, diff --git a/lib/features/chat/notifiers/chat_list_notifier.dart b/lib/features/messages/notifiers/messages_list_notifier.dart similarity index 69% rename from lib/features/chat/notifiers/chat_list_notifier.dart rename to lib/features/messages/notifiers/messages_list_notifier.dart index f0c7130a..534566b2 100644 --- a/lib/features/chat/notifiers/chat_list_notifier.dart +++ b/lib/features/messages/notifiers/messages_list_notifier.dart @@ -1,16 +1,16 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'chat_list_state.dart'; +import 'messages_list_state.dart'; -class ChatListNotifier extends StateNotifier { - ChatListNotifier() : super(const ChatListState()) { +class MessagesListNotifier extends StateNotifier { + MessagesListNotifier() : super(const MessagesListState()) { loadChats(); } Future loadChats() async { try { // 1) Start loading - state = state.copyWith(status: ChatListStatus.loading); + state = state.copyWith(status: MessagesListStatus.loading); // 2) Simulate or fetch real chat data from a repository // For example: @@ -21,14 +21,14 @@ class ChatListNotifier extends StateNotifier { // 3) Loaded state = state.copyWith( - status: ChatListStatus.loaded, + status: MessagesListStatus.loaded, chats: chats, errorMessage: null, ); } catch (e) { // On error state = state.copyWith( - status: ChatListStatus.error, + status: MessagesListStatus.error, errorMessage: e.toString(), ); } diff --git a/lib/features/chat/notifiers/chat_list_state.dart b/lib/features/messages/notifiers/messages_list_state.dart similarity index 59% rename from lib/features/chat/notifiers/chat_list_state.dart rename to lib/features/messages/notifiers/messages_list_state.dart index 0467bd4a..f8ec8a42 100644 --- a/lib/features/chat/notifiers/chat_list_state.dart +++ b/lib/features/messages/notifiers/messages_list_state.dart @@ -1,25 +1,25 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; -enum ChatListStatus { loading, loaded, error, empty } +enum MessagesListStatus { loading, loaded, error, empty } -class ChatListState { - final ChatListStatus status; +class MessagesListState { + final MessagesListStatus status; final List chats; final String? errorMessage; - const ChatListState({ - this.status = ChatListStatus.loading, + const MessagesListState({ + this.status = MessagesListStatus.loading, this.chats = const [], this.errorMessage, }); // A copyWith for convenience - ChatListState copyWith({ - ChatListStatus? status, + MessagesListState copyWith({ + MessagesListStatus? status, List? chats, String? errorMessage, }) { - return ChatListState( + return MessagesListState( status: status ?? this.status, chats: chats ?? this.chats, errorMessage: errorMessage ?? this.errorMessage, diff --git a/lib/features/messages/providers/messages_list_provider.dart b/lib/features/messages/providers/messages_list_provider.dart new file mode 100644 index 00000000..acf69d61 --- /dev/null +++ b/lib/features/messages/providers/messages_list_provider.dart @@ -0,0 +1,15 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/messages/notifiers/messages_detail_notifier.dart'; +import 'package:mostro_mobile/features/messages/notifiers/messages_detail_state.dart'; +import 'package:mostro_mobile/features/messages/notifiers/messages_list_notifier.dart'; +import 'package:mostro_mobile/features/messages/notifiers/messages_list_state.dart'; + +final messagesListNotifierProvider = + StateNotifierProvider( + (ref) => MessagesListNotifier(), +); + +final messagesDetailNotifierProvider = StateNotifierProvider.family< + MessagesDetailNotifier, MessagesDetailState, String>((ref, chatId) { + return MessagesDetailNotifier(chatId); +}); diff --git a/lib/features/chat/screens/chat_detail_screen.dart b/lib/features/messages/screens/messages_detail_screen.dart similarity index 79% rename from lib/features/chat/screens/chat_detail_screen.dart rename to lib/features/messages/screens/messages_detail_screen.dart index 59f69099..b5a64ba4 100644 --- a/lib/features/chat/screens/chat_detail_screen.dart +++ b/lib/features/messages/screens/messages_detail_screen.dart @@ -2,25 +2,26 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/features/chat/notifiers/chat_detail_state.dart'; -import 'package:mostro_mobile/features/chat/providers/chat_list_provider.dart'; +import 'package:mostro_mobile/features/messages/notifiers/messages_detail_state.dart'; +import 'package:mostro_mobile/features/messages/providers/messages_list_provider.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; -class ChatDetailScreen extends ConsumerStatefulWidget { +class MessagesDetailScreen extends ConsumerStatefulWidget { final String chatId; - const ChatDetailScreen({super.key, required this.chatId}); + const MessagesDetailScreen({super.key, required this.chatId}); @override - ConsumerState createState() => _ChatDetailScreenState(); + ConsumerState createState() => _MessagesDetailScreenState(); } -class _ChatDetailScreenState extends ConsumerState { +class _MessagesDetailScreenState extends ConsumerState { final TextEditingController _textController = TextEditingController(); @override Widget build(BuildContext context) { - final chatDetailState = ref.watch(chatDetailNotifierProvider(widget.chatId)); + final chatDetailState = + ref.watch(messagesDetailNotifierProvider(widget.chatId)); return Scaffold( backgroundColor: const Color(0xFF1D212C), @@ -38,11 +39,11 @@ class _ChatDetailScreenState extends ConsumerState { ); } - Widget _buildBody(ChatDetailState state) { + Widget _buildBody(MessagesDetailState state) { switch (state.status) { - case ChatDetailStatus.loading: + case MessagesDetailStatus.loading: return const Center(child: CircularProgressIndicator()); - case ChatDetailStatus.loaded: + case MessagesDetailStatus.loaded: return Column( children: [ Expanded( @@ -57,7 +58,7 @@ class _ChatDetailScreenState extends ConsumerState { _buildMessageInput(), ], ); - case ChatDetailStatus.error: + case MessagesDetailStatus.error: return Center(child: Text(state.error ?? 'An error occurred')); } } @@ -112,7 +113,8 @@ class _ChatDetailScreenState extends ConsumerState { final text = _textController.text.trim(); if (text.isNotEmpty) { ref - .read(chatDetailNotifierProvider(widget.chatId).notifier) + .read( + messagesDetailNotifierProvider(widget.chatId).notifier) .sendMessage(text); _textController.clear(); } diff --git a/lib/features/chat/screens/chat_list_screen.dart b/lib/features/messages/screens/messages_list_screen.dart similarity index 88% rename from lib/features/chat/screens/chat_list_screen.dart rename to lib/features/messages/screens/messages_list_screen.dart index cea94259..861c8bf5 100644 --- a/lib/features/chat/screens/chat_list_screen.dart +++ b/lib/features/messages/screens/messages_list_screen.dart @@ -2,19 +2,19 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:mostro_mobile/features/chat/notifiers/chat_list_state.dart'; -import 'package:mostro_mobile/features/chat/providers/chat_list_provider.dart'; +import 'package:mostro_mobile/features/messages/notifiers/messages_list_state.dart'; +import 'package:mostro_mobile/features/messages/providers/messages_list_provider.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; -class ChatListScreen extends ConsumerWidget { - const ChatListScreen({super.key}); +class MessagesListScreen extends ConsumerWidget { + const MessagesListScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // Watch the state - final chatListState = ref.watch(chatListNotifierProvider); + final chatListState = ref.watch(messagesListNotifierProvider); return Scaffold( backgroundColor: const Color(0xFF1D212C), @@ -31,7 +31,7 @@ class ChatListScreen extends ConsumerWidget { Padding( padding: const EdgeInsets.all(16.0), child: Text( - 'Chat', + 'Messages', style: TextStyle( color: Colors.white, fontSize: 24, @@ -50,11 +50,11 @@ class ChatListScreen extends ConsumerWidget { ); } - Widget _buildBody(ChatListState state) { + Widget _buildBody(MessagesListState state) { switch (state.status) { - case ChatListStatus.loading: + case MessagesListStatus.loading: return const Center(child: CircularProgressIndicator()); - case ChatListStatus.loaded: + case MessagesListStatus.loaded: if (state.chats.isEmpty) { return const Center( child: Text('No chats available', @@ -66,14 +66,14 @@ class ChatListScreen extends ConsumerWidget { return ChatListItem(chat: state.chats[index]); }, ); - case ChatListStatus.error: + case MessagesListStatus.error: return Center( child: Text( state.errorMessage ?? 'An error occurred', style: const TextStyle(color: Colors.red), ), ); - case ChatListStatus.empty: + case MessagesListStatus.empty: return const Center( child: Text('No chats available', style: TextStyle(color: Colors.white))); diff --git a/lib/features/mostro/screens/mostro_screen.dart b/lib/features/mostro/screens/mostro_screen.dart index 692761bb..ab8b29d3 100644 --- a/lib/features/mostro/screens/mostro_screen.dart +++ b/lib/features/mostro/screens/mostro_screen.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/features/order_book/notifiers/order_book_state.dart'; -import 'package:mostro_mobile/features/order_book/providers/order_book_notifier.dart'; -import 'package:mostro_mobile/features/order_book/widgets/order_book_list.dart'; +import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; +import 'package:mostro_mobile/features/trades/providers/trades_notifier.dart'; +import 'package:mostro_mobile/features/trades/widgets/trades_list.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; @@ -14,7 +14,7 @@ class MostroScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final orderBookStateAsync = ref.watch(orderBookNotifierProvider); + final orderBookStateAsync = ref.watch(tradesNotifierProvider); return orderBookStateAsync.when( data: (orderBookState) { @@ -72,7 +72,7 @@ class MostroScreen extends ConsumerWidget { ); } - Widget _buildOrderList(OrderBookState orderBookState) { + Widget _buildOrderList(TradesState orderBookState) { if (orderBookState.orders.isEmpty) { return const Center( child: Text( @@ -82,6 +82,6 @@ class MostroScreen extends ConsumerWidget { ); } - return OrderBookList(orders: orderBookState.orders); + return TradesList(orders: orderBookState.orders); } } diff --git a/lib/features/order_book/notifiers/order_book_notifier.dart b/lib/features/order_book/notifiers/order_book_notifier.dart deleted file mode 100644 index 16cff83b..00000000 --- a/lib/features/order_book/notifiers/order_book_notifier.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/order_book/notifiers/order_book_state.dart'; - -class OrderBookNotifier extends AsyncNotifier { - @override - FutureOr build() async { - state = const AsyncLoading(); - - return OrderBookState([]); - - } - -} \ No newline at end of file diff --git a/lib/features/order_book/providers/order_book_notifier.dart b/lib/features/order_book/providers/order_book_notifier.dart deleted file mode 100644 index c3a7432d..00000000 --- a/lib/features/order_book/providers/order_book_notifier.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/order_book/notifiers/order_book_notifier.dart'; -import 'package:mostro_mobile/features/order_book/notifiers/order_book_state.dart'; - -final orderBookNotifierProvider = AsyncNotifierProvider( - OrderBookNotifier.new, -); - diff --git a/lib/features/take_order/screens/take_order_screen.dart b/lib/features/take_order/screens/take_order_screen.dart index 678a1a87..568f75e8 100644 --- a/lib/features/take_order/screens/take_order_screen.dart +++ b/lib/features/take_order/screens/take_order_screen.dart @@ -57,11 +57,6 @@ class TakeOrderScreen extends ConsumerWidget { if (order.currency != null) ExchangeRateWidget(currency: order.currency!), const SizedBox(height: 16), - CustomCard( - padding: const EdgeInsets.all(16), - child: BuyerInfo(order: order), - ), - const SizedBox(height: 16), _buildBuyerAmount(int.tryParse(order.amount!)), const SizedBox(height: 16), _buildLnAddress(), diff --git a/lib/features/trades/notifiers/trades_notifier.dart b/lib/features/trades/notifiers/trades_notifier.dart new file mode 100644 index 00000000..fa4091eb --- /dev/null +++ b/lib/features/trades/notifiers/trades_notifier.dart @@ -0,0 +1,13 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; + +class TradesNotifier extends AsyncNotifier { + @override + FutureOr build() async { + state = const AsyncLoading(); + + return TradesState([]); + } +} diff --git a/lib/features/order_book/notifiers/order_book_state.dart b/lib/features/trades/notifiers/trades_state.dart similarity index 61% rename from lib/features/order_book/notifiers/order_book_state.dart rename to lib/features/trades/notifiers/trades_state.dart index a6d21acb..e7988d88 100644 --- a/lib/features/order_book/notifiers/order_book_state.dart +++ b/lib/features/trades/notifiers/trades_state.dart @@ -1,7 +1,7 @@ import 'package:mostro_mobile/data/models/order.dart'; -class OrderBookState { +class TradesState { final List orders; - OrderBookState(this.orders); + TradesState(this.orders); } diff --git a/lib/features/trades/providers/trades_notifier.dart b/lib/features/trades/providers/trades_notifier.dart new file mode 100644 index 00000000..ced0a6b2 --- /dev/null +++ b/lib/features/trades/providers/trades_notifier.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/trades/notifiers/trades_notifier.dart'; +import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; + +final tradesNotifierProvider = + AsyncNotifierProvider( + TradesNotifier.new, +); diff --git a/lib/features/order_book/screens/order_book_screen.dart b/lib/features/trades/screens/trades_screen.dart similarity index 81% rename from lib/features/order_book/screens/order_book_screen.dart rename to lib/features/trades/screens/trades_screen.dart index 85b3b0b9..1ac20714 100644 --- a/lib/features/order_book/screens/order_book_screen.dart +++ b/lib/features/trades/screens/trades_screen.dart @@ -2,19 +2,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/features/order_book/notifiers/order_book_state.dart'; -import 'package:mostro_mobile/features/order_book/providers/order_book_notifier.dart'; -import 'package:mostro_mobile/features/order_book/widgets/order_book_list.dart'; +import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; +import 'package:mostro_mobile/features/trades/providers/trades_notifier.dart'; +import 'package:mostro_mobile/features/trades/widgets/trades_list.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; -class OrderBookScreen extends ConsumerWidget { - const OrderBookScreen({super.key}); +class TradesScreen extends ConsumerWidget { + const TradesScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final orderBookStateAsync = ref.watch(orderBookNotifierProvider); + final orderBookStateAsync = ref.watch(tradesNotifierProvider); return orderBookStateAsync.when( data: (orderBookState) { @@ -37,7 +37,7 @@ class OrderBookScreen extends ConsumerWidget { Padding( padding: EdgeInsets.all(16.0), child: Text( - 'Order Book', + 'My Trades', style: TextStyle( color: Colors.white, fontSize: 24, @@ -72,7 +72,7 @@ class OrderBookScreen extends ConsumerWidget { ); } - Widget _buildOrderList(OrderBookState orderBookState) { + Widget _buildOrderList(TradesState orderBookState) { if (orderBookState.orders.isEmpty) { return const Center( child: Text( @@ -82,6 +82,6 @@ class OrderBookScreen extends ConsumerWidget { ); } - return OrderBookList(orders: orderBookState.orders); + return TradesList(orders: orderBookState.orders); } } diff --git a/lib/features/order_book/widgets/order_book_list.dart b/lib/features/trades/widgets/trades_list.dart similarity index 54% rename from lib/features/order_book/widgets/order_book_list.dart rename to lib/features/trades/widgets/trades_list.dart index e2967c02..bae3e2bb 100644 --- a/lib/features/order_book/widgets/order_book_list.dart +++ b/lib/features/trades/widgets/trades_list.dart @@ -1,18 +1,18 @@ import 'package:flutter/material.dart'; import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/features/order_book/widgets/order_book_list_item.dart'; +import 'package:mostro_mobile/features/trades/widgets/trades_list_item.dart'; -class OrderBookList extends StatelessWidget { +class TradesList extends StatelessWidget { final List orders; - const OrderBookList({super.key, required this.orders}); + const TradesList({super.key, required this.orders}); @override Widget build(BuildContext context) { return ListView.builder( itemCount: orders.length, itemBuilder: (context, index) { - return OrderBookListItem(order: orders[index]); + return TradesListItem(order: orders[index]); }, ); } diff --git a/lib/features/order_book/widgets/order_book_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart similarity index 97% rename from lib/features/order_book/widgets/order_book_list_item.dart rename to lib/features/trades/widgets/trades_list_item.dart index 355a501f..20516e2f 100644 --- a/lib/features/order_book/widgets/order_book_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -5,16 +5,15 @@ import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; -class OrderBookListItem extends StatelessWidget { +class TradesListItem extends StatelessWidget { final Order order; - const OrderBookListItem({super.key, required this.order}); + const TradesListItem({super.key, required this.order}); @override Widget build(BuildContext context) { return GestureDetector( - onTap: () { - }, + onTap: () {}, child: CustomCard( color: AppTheme.dark1, margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), From ba68084bcd65e64db5b710665cda1c4c6aa997fd Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 12 Feb 2025 12:12:44 -0800 Subject: [PATCH 040/149] switch persistence to sembat and updated dart_nostr to latest version --- android/app/build.gradle | 4 +- lib/app/config.dart | 3 + .../order_repository_encrypted.dart | 181 ++++++++--------- lib/data/repositories/session_manager.dart | 123 +++++------- lib/data/repositories/session_storage.dart | 107 ++++++++++ .../key_manager/key_management_screen.dart | 13 ++ .../mostro/screens/mostro_screen.dart | 21 +- .../screens/add_lightning_invoice_screen.dart | 1 - .../screens/pay_lightning_invoice_screen.dart | 1 - .../take_order/screens/take_order_screen.dart | 1 - .../trades/providers/trades_notifier.dart | 8 - .../trades/providers/trades_provider.dart | 8 + .../trades/screens/trades_screen.dart | 30 +-- lib/features/trades/widgets/trades_list.dart | 10 +- .../trades/widgets/trades_list_item.dart | 187 +++++------------- lib/main.dart | 3 + lib/services/nostr_service.dart | 8 +- lib/shared/providers/app_init_provider.dart | 7 +- .../providers/mostro_database_provider.dart | 17 ++ .../providers/session_manager_provider.dart | 6 +- .../providers/session_storage_provider.dart | 10 + lib/shared/utils/nostr_utils.dart | 24 +-- lib/shared/widgets/privacy_switch_widget.dart | 60 ++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 30 ++- pubspec.yaml | 5 +- 26 files changed, 454 insertions(+), 416 deletions(-) create mode 100644 lib/data/repositories/session_storage.dart delete mode 100644 lib/features/trades/providers/trades_notifier.dart create mode 100644 lib/features/trades/providers/trades_provider.dart create mode 100644 lib/shared/providers/mostro_database_provider.dart create mode 100644 lib/shared/providers/session_storage_provider.dart create mode 100644 lib/shared/widgets/privacy_switch_widget.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index fba96afe..6233e994 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,7 +7,7 @@ plugins { android { namespace = "com.example.mostro_mobile" - compileSdk = flutter.compileSdkVersion + compileSdk = 35 // flutter.compileSdkVersion ndkVersion = "25.1.8937393" //flutter.ndkVersion compileOptions { @@ -24,7 +24,7 @@ android { applicationId = "com.example.mostro_mobile" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 23 // flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/lib/app/config.dart b/lib/app/config.dart index ce36dfbc..bd0098ff 100644 --- a/lib/app/config.dart +++ b/lib/app/config.dart @@ -14,6 +14,9 @@ class Config { static const String mostroPubKey = '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; + static const String dBName = 'mostro.db'; + static const String dBPassword = 'mostro'; + // Tiempo de espera para conexiones a relays static const Duration nostrConnectionTimeout = Duration(seconds: 30); diff --git a/lib/data/repositories/order_repository_encrypted.dart b/lib/data/repositories/order_repository_encrypted.dart index 86d4d883..04ade39c 100644 --- a/lib/data/repositories/order_repository_encrypted.dart +++ b/lib/data/repositories/order_repository_encrypted.dart @@ -1,131 +1,104 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; -import 'package:path/path.dart'; -import 'package:sqflite_sqlcipher/sqflite.dart'; +import 'package:sembast/sembast.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; -class OrderRepositoryEncrypted implements OrderRepository { - static const _dbName = 'orders_encrypted.db'; - static const _dbVersion = 1; - static const _tableName = 'orders'; +/// Example (somewhat minimal) repository for storing and retrieving +/// orders in a Sembast database. +class OrderRepositoryEncrypted implements OrderRepository { + final Logger _logger = Logger(); + final Database _database; + final StoreRef> _ordersStore = + stringMapStoreFactory.store('orders'); - final String _dbPassword; + OrderRepositoryEncrypted(this._database); - Database? _database; - - OrderRepositoryEncrypted({required String dbPassword}) - : _dbPassword = dbPassword; - - /// Return the single instance of Database, opening if needed - Future get database async { - if (_database != null) return _database!; - _database = await _initDatabase(); - return _database!; - } - - /// Initialize the encrypted database - Future _initDatabase() async { - final docDir = await getDatabasesPath(); - final dbPath = join(docDir, _dbName); - - return await openDatabase( - dbPath, - password: _dbPassword, - version: _dbVersion, - onCreate: _onCreate, - // onUpgrade: _onUpgrade if needed - ); + /// Save or update a MostroMessage (with an Order payload) in Sembast + @override + Future addOrder(MostroMessage message) async { + final orderId = message.id; + if (orderId == null) { + throw ArgumentError('Cannot save an order with a null message.id'); + } + // Convert to JSON so we can store as a Map + final jsonMap = message.toJson(); + await _ordersStore.record(orderId).put(_database, jsonMap); + _logger.i('Order $orderId saved to Sembast'); } - Future _onCreate(Database db, int version) async { - // Create table with all columns from the Order model - // id is primary key, so if you don't expect collisions, use that - await db.execute(''' - CREATE TABLE $_tableName ( - id TEXT PRIMARY KEY, - kind TEXT, - status TEXT, - amount INTEGER, - fiatCode TEXT, - minAmount INTEGER, - maxAmount INTEGER, - fiatAmount INTEGER, - paymentMethod TEXT, - premium INTEGER, - masterBuyerPubkey TEXT, - masterSellerPubkey TEXT, - buyerInvoice TEXT, - createdAt INTEGER, - expiresAt INTEGER, - buyerToken INTEGER, - sellerToken INTEGER - ) - '''); + /// Retrieve an order by ID + @override + Future?> getOrderById(String orderId) async { + final record = await _ordersStore.record(orderId).get(_database); + if (record == null) return null; + try { + final msg = MostroMessage.deserialized(jsonEncode(record)); + // If the payload is indeed an Order, you can cast or do a check: + // final order = msg.getPayload(); + // ... + return msg as MostroMessage; + } catch (e) { + _logger.e('Error deserializing order $orderId: $e'); + return null; + } } - // region: CRUD - + /// Return all orders @override - Future addOrder(Order order) async { - final db = await database; - await db.insert( - _tableName, - order.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, - ); + Future> getAllOrders() async { + final records = await _ordersStore.find(_database); + final results = >[]; + for (final record in records) { + try { + final msg = MostroMessage.deserialized(jsonEncode(record.value)); + results.add(msg as MostroMessage); + } catch (e) { + _logger.e('Error deserializing order with key ${record.key}: $e'); + } + } + return results; } + /// Delete an order from DB @override - Future> getAllOrders() async { - final db = await database; - final results = await db.query(_tableName); - return results.map((map) => Order.fromMap(map)).toList(); + Future deleteOrder(String orderId) async { + await _ordersStore.record(orderId).delete(_database); } - @override - Future getOrderById(String orderId) async { - final db = await database; - final results = await db.query( - _tableName, - where: 'id = ?', - whereArgs: [orderId], - ); - if (results.isNotEmpty) { - return Order.fromMap(results.first); - } - return null; + /// Delete all orders + Future deleteAllOrders() async { + await _ordersStore.delete(_database); } - @override - Future updateOrder(Order order) async { - // The order must have a valid id - if (order.id == null) { - throw ArgumentError('Cannot update an Order with null ID'); + /// Example usage: you might have a function to update the status or action + Future updateAction(String orderId, Action newAction) async { + final record = await _ordersStore.record(orderId).get(_database); + if (record == null) { + // no such order + return; } - - final db = await database; - await db.update( - _tableName, - order.toMap(), - where: 'id = ?', - whereArgs: [order.id], - ); + record['order']['action'] = newAction.value; + await _ordersStore.record(orderId).put(_database, record); } @override - Future deleteOrder(String orderId) async { - final db = await database; - await db.delete( - _tableName, - where: 'id = ?', - whereArgs: [orderId], - ); + void dispose() { + // If needed } - // endregion - @override - void dispose() { - // TODO: implement dispose + Future updateOrder(MostroMessage message) async { + final orderId = message.id; + if (orderId == null) { + throw ArgumentError('Cannot save an order with a null message.id'); + } + // Convert to JSON so we can store as a Map + final jsonMap = message.toJson(); + await _ordersStore.record(orderId).put(_database, jsonMap); + _logger.i('Order $orderId saved to Sembast'); } } diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 072b0080..398b77d1 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -1,46 +1,43 @@ import 'dart:async'; -import 'dart:convert'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:logger/logger.dart'; -import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; -import 'package:mostro_mobile/features/key_manager/key_manager.dart'; import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/repositories/session_storage.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager.dart'; class SessionManager { final KeyManager _keyManager; - final FlutterSecureStorage _secureStorage; + final SessionStorage _sessionStorage; + final Logger _logger = Logger(); + + // In-memory session cache final Map _sessions = {}; - final _logger = Logger(); Timer? _cleanupTimer; final int sessionExpirationHours = 48; static const cleanupIntervalMinutes = 30; static const maxBatchSize = 100; - SessionManager(this._keyManager, this._secureStorage) { + SessionManager( + this._keyManager, + this._sessionStorage, + ) { _initializeCleanup(); } - /// Call this after app startup to load sessions from storage + /// Load all sessions at startup and populate the in-memory map. Future init() async { - final allEntries = await _secureStorage.readAll(); - for (final entry in allEntries.entries) { - if (entry.key.startsWith(SecureStorageKeys.sessionKey.value)) { - try { - final session = await _decodeSession(entry.value); - _sessions[session.keyIndex] = session; - } catch (e) { - _logger.e('Error decoding session for key ${entry.key}: $e'); - // Decide if you want to remove the corrupted entry - } - } + final allSessions = await _sessionStorage.getAllSessions(); + for (final session in allSessions) { + _sessions[session.keyIndex] = session; } } + /// Creates a new session, storing it both in memory and in the database. Future newSession({String? orderId}) async { final masterKey = await _keyManager.getMasterKey(); final keyIndex = await _keyManager.getCurrentKeyIndex(); final tradeKey = await _keyManager.deriveTradeKey(); + final session = Session( startTime: DateTime.now(), masterKey: masterKey, @@ -49,18 +46,22 @@ class SessionManager { fullPrivacy: true, orderId: orderId, ); + + // Cache it in memory _sessions[keyIndex] = session; - await saveSession(session); + // Persist it in the database + await _sessionStorage.putSession(session); + return session; } + /// Update a session in both memory and the database. Future saveSession(Session session) async { - String sessionJson = jsonEncode(session.toJson()); - await _secureStorage.write( - key: '${SecureStorageKeys.sessionKey}${session.keyIndex}', - value: sessionJson); + _sessions[session.keyIndex] = session; + await _sessionStorage.putSession(session); } + /// Retrieve the first session that matches a given orderId (from the in-memory map). Session? getSessionByOrderId(String orderId) { try { return _sessions.values.firstWhere((s) => s.orderId == orderId); @@ -69,66 +70,34 @@ class SessionManager { } } + /// Retrieve a session by its keyIndex (checks memory first, then DB). Future loadSession(int keyIndex) async { if (_sessions.containsKey(keyIndex)) { return _sessions[keyIndex]; } - final storedJson = await _secureStorage.read( - key: '${SecureStorageKeys.sessionKey}$keyIndex'); - if (storedJson != null) { - try { - final session = await _decodeSession(storedJson); - _sessions[keyIndex] = session; - return session; - } catch (e) { - _logger.e('Error decoding session index $keyIndex: $e'); - } + final session = await _sessionStorage.getSession(keyIndex); + if (session != null) { + _sessions[keyIndex] = session; } - return null; - } - - Future _decodeSession(String jsonData) async { - final map = jsonDecode(jsonData) as Map; - final index = map['key_index'] as int; - final tradeKey = await _keyManager.deriveTradeKeyFromIndex(index); - final masterKey = await _keyManager.getMasterKey(); - map['trade_key'] = tradeKey; - map['master_key'] = masterKey; - return Session.fromJson(map); + return session; } + /// Removes a session from memory and the database. Future deleteSession(int sessionId) async { _sessions.remove(sessionId); - await _secureStorage.delete( - key: '${SecureStorageKeys.sessionKey}$sessionId'); + await _sessionStorage.deleteSession(sessionId); } + /// Periodically clear out expired sessions. Future clearExpiredSessions() async { try { - final now = DateTime.now(); - final allEntries = await _secureStorage.readAll(); - final entries = allEntries.entries - .where((e) => e.key.startsWith(SecureStorageKeys.sessionKey.value)) - .toList(); - - int processedCount = 0; - for (final entry in entries) { - if (processedCount >= maxBatchSize) break; - try { - final sessionMap = jsonDecode(entry.value) as Map; - final startTime = DateTime.parse(sessionMap['start_time'] as String); - final index = sessionMap['key_index'] as int; - if (now.difference(startTime).inHours >= sessionExpirationHours) { - await _secureStorage.delete(key: entry.key); - _sessions.remove(index); - processedCount++; - } - } catch (e) { - _logger.e('Error processing session ${entry.key}: $e'); - // Possibly remove corrupted entry - await _secureStorage.delete(key: entry.key); - processedCount++; - } + final removedIds = await _sessionStorage.deleteExpiredSessions( + sessionExpirationHours, + maxBatchSize, + ); + // Remove them from the in-memory map + for (final id in removedIds) { + _sessions.remove(id); } } catch (e) { _logger.e('Error during session cleanup: $e'); @@ -137,16 +106,20 @@ class SessionManager { void _initializeCleanup() { _cleanupTimer?.cancel(); + // Perform an initial cleanup clearExpiredSessions(); - _cleanupTimer = - Timer.periodic(Duration(minutes: cleanupIntervalMinutes), (timer) { - clearExpiredSessions(); - }); + // Schedule periodic cleanup + _cleanupTimer = Timer.periodic( + const Duration(minutes: cleanupIntervalMinutes), + (_) => clearExpiredSessions(), + ); } + /// Dispose resources (e.g., timers) when no longer needed. void dispose() { _cleanupTimer?.cancel(); } + /// Returns all in-memory sessions. List get sessions => _sessions.values.toList(); } diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart new file mode 100644 index 00000000..5777b05d --- /dev/null +++ b/lib/data/repositories/session_storage.dart @@ -0,0 +1,107 @@ +import 'package:sembast/sembast.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager.dart'; + +class SessionStorage { + final Database _database; + final KeyManager _keyManager; + final _logger = Logger(); + + // Store reference for sessions + final StoreRef> _store = + intMapStoreFactory.store('sessions'); + + SessionStorage( + this._database, + this._keyManager, + ); + + /// Retrieves all sessions from the database (decodes them as well). + Future> getAllSessions() async { + final records = await _store.find(_database); + final sessions = []; + for (final record in records) { + try { + final session = await _decodeSession(record.value); + sessions.add(session); + } catch (e) { + _logger.e('Error decoding session for key ${record.key}: $e'); + // Optionally handle or remove corrupted records + } + } + return sessions; + } + + /// Retrieves one session by keyIndex. + Future getSession(int keyIndex) async { + final record = await _store.record(keyIndex).get(_database); + if (record == null) { + return null; + } + try { + return await _decodeSession(record); + } catch (e) { + _logger.e('Error decoding session index $keyIndex: $e'); + return null; + } + } + + /// Saves (inserts or updates) a session in the database. + Future putSession(Session session) async { + final jsonMap = session.toJson(); + // Use the session's keyIndex as the DB key + await _store.record(session.keyIndex).put(_database, jsonMap); + } + + /// Deletes a specific session from the database. + Future deleteSession(int keyIndex) async { + await _store.record(keyIndex).delete(_database); + } + + /// Finds and deletes sessions considered expired, returning a list of deleted IDs. + /// (Keeps domain logic here for simplicity; you could also move it into the Manager.) + Future> deleteExpiredSessions(int sessionExpirationHours, int maxBatchSize) async { + final now = DateTime.now(); + final records = await _store.find(_database); + final removedIds = []; + + for (final record in records) { + if (removedIds.length >= maxBatchSize) break; + + try { + final sessionMap = record.value; + final startTimeStr = sessionMap['start_time'] as String?; + if (startTimeStr != null) { + final startTime = DateTime.parse(startTimeStr); + if (now.difference(startTime).inHours >= sessionExpirationHours) { + await _store.record(record.key).delete(_database); + removedIds.add(record.key); + } + } + } catch (e) { + // Possibly remove corrupted record + _logger.e('Error processing session ${record.key}: $e'); + await _store.record(record.key).delete(_database); + removedIds.add(record.key); + } + } + + return removedIds; + } + + /// Rebuilds a [Session] object from the DB record by re-deriving keys. + Future _decodeSession(Map map) async { + final index = map['key_index'] as int; + + // Re-derive trade key from index + final tradeKey = await _keyManager.deriveTradeKeyFromIndex(index); + // Re-get masterKey (potentially from secure storage/caching) + final masterKey = await _keyManager.getMasterKey(); + + map['trade_key'] = tradeKey; + map['master_key'] = masterKey; + + return Session.fromJson(map); + } +} diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index a0cb19f9..f94a1998 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -6,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/app/app_theme.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/shared/widgets/privacy_switch_widget.dart'; class KeyManagementScreen extends ConsumerStatefulWidget { const KeyManagementScreen({super.key}); @@ -88,6 +89,8 @@ class _KeyManagementScreenState extends ConsumerState { @override Widget build(BuildContext context) { + bool isFullPrivacy = false; + return Scaffold( appBar: AppBar( backgroundColor: Colors.transparent, @@ -185,6 +188,16 @@ class _KeyManagementScreenState extends ConsumerState { onPressed: _importKey, child: const Text('Import Key'), ), + const SizedBox(height: 16), + const Text( + 'Privacy', + style: TextStyle(color: AppTheme.cream1, fontSize: 18), + ), + const SizedBox(height: 8), + PrivacySwitch( + initialValue: isFullPrivacy, + onChanged: (newValue) {}, + ), ], ), ), diff --git a/lib/features/mostro/screens/mostro_screen.dart b/lib/features/mostro/screens/mostro_screen.dart index ab8b29d3..9fad43b4 100644 --- a/lib/features/mostro/screens/mostro_screen.dart +++ b/lib/features/mostro/screens/mostro_screen.dart @@ -2,9 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; -import 'package:mostro_mobile/features/trades/providers/trades_notifier.dart'; -import 'package:mostro_mobile/features/trades/widgets/trades_list.dart'; +import 'package:mostro_mobile/features/trades/providers/trades_provider.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; @@ -14,7 +12,7 @@ class MostroScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final orderBookStateAsync = ref.watch(tradesNotifierProvider); + final orderBookStateAsync = ref.watch(tradesProvider); return orderBookStateAsync.when( data: (orderBookState) { @@ -46,9 +44,6 @@ class MostroScreen extends ConsumerWidget { ), ), ), - Expanded( - child: _buildOrderList(orderBookState), - ), const BottomNavBar(), ], ), @@ -72,16 +67,4 @@ class MostroScreen extends ConsumerWidget { ); } - Widget _buildOrderList(TradesState orderBookState) { - if (orderBookState.orders.isEmpty) { - return const Center( - child: Text( - 'No orders available for this type', - style: TextStyle(color: Colors.white), - ), - ); - } - - return TradesList(orders: orderBookState.orders); - } } diff --git a/lib/features/take_order/screens/add_lightning_invoice_screen.dart b/lib/features/take_order/screens/add_lightning_invoice_screen.dart index c1829255..420dfa3c 100644 --- a/lib/features/take_order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/add_lightning_invoice_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/app/app_theme.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; -import 'package:mostro_mobile/features/home/notifiers/home_notifier.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; diff --git a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart b/lib/features/take_order/screens/pay_lightning_invoice_screen.dart index 60ef2a7c..edbb38f2 100644 --- a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/pay_lightning_invoice_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; diff --git a/lib/features/take_order/screens/take_order_screen.dart b/lib/features/take_order/screens/take_order_screen.dart index 568f75e8..17558df9 100644 --- a/lib/features/take_order/screens/take_order_screen.dart +++ b/lib/features/take_order/screens/take_order_screen.dart @@ -6,7 +6,6 @@ import 'package:mostro_mobile/app/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/features/take_order/widgets/order_app_bar.dart'; -import 'package:mostro_mobile/features/take_order/widgets/buyer_info.dart'; import 'package:mostro_mobile/features/take_order/widgets/seller_info.dart'; import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; import 'package:mostro_mobile/shared/widgets/exchange_rate_widget.dart'; diff --git a/lib/features/trades/providers/trades_notifier.dart b/lib/features/trades/providers/trades_notifier.dart deleted file mode 100644 index ced0a6b2..00000000 --- a/lib/features/trades/providers/trades_notifier.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/trades/notifiers/trades_notifier.dart'; -import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; - -final tradesNotifierProvider = - AsyncNotifierProvider( - TradesNotifier.new, -); diff --git a/lib/features/trades/providers/trades_provider.dart b/lib/features/trades/providers/trades_provider.dart new file mode 100644 index 00000000..ee4b4b81 --- /dev/null +++ b/lib/features/trades/providers/trades_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; + +final tradesProvider = FutureProvider>((ref) async { + final sessionManager = ref.read(sessionManagerProvider); + return sessionManager.sessions; +}); diff --git a/lib/features/trades/screens/trades_screen.dart b/lib/features/trades/screens/trades_screen.dart index 1ac20714..b3501561 100644 --- a/lib/features/trades/screens/trades_screen.dart +++ b/lib/features/trades/screens/trades_screen.dart @@ -2,29 +2,30 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; -import 'package:mostro_mobile/features/trades/providers/trades_notifier.dart'; +import 'package:mostro_mobile/features/trades/providers/trades_provider.dart'; import 'package:mostro_mobile/features/trades/widgets/trades_list.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; +import 'package:mostro_mobile/data/models/session.dart'; class TradesScreen extends ConsumerWidget { const TradesScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final orderBookStateAsync = ref.watch(tradesNotifierProvider); + final tradesAsync = ref.watch(tradesProvider); - return orderBookStateAsync.when( - data: (orderBookState) { + return tradesAsync.when( + data: (sessions) { return Scaffold( backgroundColor: AppTheme.dark1, appBar: const MostroAppBar(), drawer: const MostroAppDrawer(), body: RefreshIndicator( onRefresh: () async { - //await orderBookNotifier.refresh(); + // Force a refresh of sessions + ref.refresh(tradesProvider); }, child: Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), @@ -35,19 +36,20 @@ class TradesScreen extends ConsumerWidget { child: Column( children: [ Padding( - padding: EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16.0), child: Text( 'My Trades', style: TextStyle( color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold, - fontFamily: GoogleFonts.robotoCondensed().fontFamily, + fontFamily: + GoogleFonts.robotoCondensed().fontFamily, ), ), ), Expanded( - child: _buildOrderList(orderBookState), + child: _buildOrderList(sessions), ), const BottomNavBar(), ], @@ -72,16 +74,18 @@ class TradesScreen extends ConsumerWidget { ); } - Widget _buildOrderList(TradesState orderBookState) { - if (orderBookState.orders.isEmpty) { + /// If your Session contains a full order snapshot, you could convert it. + /// Otherwise, update TradesList to accept a List. + Widget _buildOrderList(List sessions) { + if (sessions.isEmpty) { return const Center( child: Text( - 'No orders available for this type', + 'No trades available for this type', style: TextStyle(color: Colors.white), ), ); } - return TradesList(orders: orderBookState.orders); + return TradesList(sessions: sessions); } } diff --git a/lib/features/trades/widgets/trades_list.dart b/lib/features/trades/widgets/trades_list.dart index bae3e2bb..67058f6b 100644 --- a/lib/features/trades/widgets/trades_list.dart +++ b/lib/features/trades/widgets/trades_list.dart @@ -1,18 +1,18 @@ import 'package:flutter/material.dart'; -import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/trades/widgets/trades_list_item.dart'; class TradesList extends StatelessWidget { - final List orders; + final List sessions; - const TradesList({super.key, required this.orders}); + const TradesList({super.key, required this.sessions}); @override Widget build(BuildContext context) { return ListView.builder( - itemCount: orders.length, + itemCount: sessions.length, itemBuilder: (context, index) { - return TradesListItem(order: orders[index]); + return TradesListItem(session: sessions[index]); }, ); } diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index 20516e2f..f71577b6 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -1,19 +1,20 @@ import 'package:flutter/material.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/data/models/enums/order_type.dart'; -import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; class TradesListItem extends StatelessWidget { - final Order order; + final Session session; - const TradesListItem({super.key, required this.order}); + const TradesListItem({super.key, required this.session}); @override Widget build(BuildContext context) { return GestureDetector( - onTap: () {}, + onTap: () { + // TODO: Navigate to a detail screen or hydrate the session with full order data. + }, child: CustomCard( color: AppTheme.dark1, margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), @@ -23,7 +24,7 @@ class TradesListItem extends StatelessWidget { children: [ _buildHeader(context), const SizedBox(height: 16), - _buildOrderDetails(context), + _buildSessionDetails(context), const SizedBox(height: 8), ], ), @@ -35,14 +36,16 @@ class TradesListItem extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + // Display the order ID (or a placeholder if not yet assigned) Text( - '${order.id}', + session.orderId ?? 'No Order', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: AppTheme.cream1, ), ), + // Display a formatted start time (for example, hour and minute) Text( - 'Time: ', + 'Time: ${session.startTime.hour.toString().padLeft(2, '0')}:${session.startTime.minute.toString().padLeft(2, '0')}', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: AppTheme.cream1, ), @@ -51,147 +54,51 @@ class TradesListItem extends StatelessWidget { ); } - Widget _buildOrderDetails(BuildContext context) { + Widget _buildSessionDetails(BuildContext context) { return Row( children: [ - _getOrderOffering(context, order), - const SizedBox(width: 16), + // Display trade key index or other session summary info Expanded( - flex: 4, - child: _buildPaymentMethod(context), - ), - ], - ); - } - - Widget _getOrderOffering(BuildContext context, Order order) { - String offering = order.kind == OrderType.buy ? 'Buying' : 'Selling'; - String amountText = '${order.amount}'; - - return Expanded( - flex: 3, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - text: TextSpan( - children: [ - _buildStyledTextSpan( - context, - offering, - amountText, - isValue: true, - isBold: true, - ), - TextSpan( - text: 'sats', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.cream1, - fontWeight: FontWeight.normal, - ), - ), - ], - ), - ), - const SizedBox(height: 8.0), - RichText( - text: TextSpan( - children: [ - _buildStyledTextSpan( - context, - 'for ', - '${order.fiatAmount}', - isValue: true, - isBold: true, - ), - TextSpan( - text: '${order.fiatCode} ', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.cream1, - fontSize: 16.0, - ), - ), - TextSpan( - text: '(${order.premium}%)', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.cream1, - fontSize: 16.0, - ), - ), - ], - ), + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Trade Key: ${session.keyIndex}', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + ), + ), + // You could add more session details here as needed. + ], ), - ], - ), - ); - } - - Widget _buildPaymentMethod(BuildContext context) { - String method = order.paymentMethod.isNotEmpty - ? order.paymentMethod - : 'No payment method'; - - return Row( - children: [ - HeroIcon( - _getPaymentMethodIcon(method), - style: HeroIconStyle.outline, - color: AppTheme.cream1, - size: 16, ), - const SizedBox(width: 4), - Flexible( - child: Text( - method, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.grey2, + // Display a placeholder for status or payment method info + Expanded( + flex: 4, + child: Row( + children: [ + HeroIcon( + HeroIcons.banknotes, + style: HeroIconStyle.outline, + color: AppTheme.cream1, + size: 16, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + 'Status: pending', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.grey2, + ), + overflow: TextOverflow.ellipsis, + softWrap: true, ), - overflow: TextOverflow.ellipsis, - softWrap: true, + ), + ], ), ), ], ); } - - HeroIcons _getPaymentMethodIcon(String method) { - switch (method.toLowerCase()) { - case 'wire transfer': - case 'transferencia bancaria': - return HeroIcons.buildingLibrary; - case 'revolut': - return HeroIcons.creditCard; - default: - return HeroIcons.banknotes; - } - } - - TextSpan _buildStyledTextSpan( - BuildContext context, - String label, - String value, { - bool isValue = false, - bool isBold = false, - }) { - return TextSpan( - text: label, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.cream1, - fontWeight: FontWeight.normal, - fontSize: isValue ? 16.0 : 24.0, - ), - children: isValue - ? [ - TextSpan( - text: '$value ', - style: Theme.of(context).textTheme.displayLarge?.copyWith( - fontWeight: isBold ? FontWeight.bold : FontWeight.normal, - fontSize: 24.0, - color: AppTheme.cream1, - ), - ), - ] - : [], - ); - } } diff --git a/lib/main.dart b/lib/main.dart index af8988ed..a95ba981 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dar import 'package:mostro_mobile/features/notifications/notification_controller.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; import 'package:mostro_mobile/shared/providers/storage_providers.dart'; import 'package:mostro_mobile/shared/utils/biometrics_helper.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -21,6 +22,7 @@ void main() async { final biometricsHelper = BiometricsHelper(); final sharedPreferences = SharedPreferencesAsync(); final secureStorage = const FlutterSecureStorage(); + final database = await openMostroDatabase(); runApp( ProviderScope( @@ -29,6 +31,7 @@ void main() async { biometricsHelperProvider.overrideWithValue(biometricsHelper), sharedPreferencesProvider.overrideWithValue(sharedPreferences), secureStorageProvider.overrideWithValue(secureStorage), + mostroDatabaseProvider.overrideWithValue(database), ], child: const MostroApp(), ), diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 10cc7b60..209f124a 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -17,7 +17,7 @@ class NostrService { _nostr = Nostr.instance; try { - await _nostr.relaysService.init( + await _nostr.services.relays.init( relaysUrl: Config.nostrRelays, connectionTimeout: Config.nostrConnectionTimeout, onRelayListening: (relay, url, channel) { @@ -41,7 +41,7 @@ class NostrService { } try { - await _nostr.relaysService.sendEventToRelaysAsync(event, + await _nostr.services.relays.sendEventToRelaysAsync(event, timeout: Config.nostrConnectionTimeout); _logger.i('Event published successfully'); } catch (e) { @@ -57,7 +57,7 @@ class NostrService { final request = NostrRequest(filters: [filter]); final subscription = - _nostr.relaysService.startEventsSubscription(request: request); + _nostr.services.relays.startEventsSubscription(request: request); return subscription.stream; } @@ -65,7 +65,7 @@ class NostrService { Future disconnectFromRelays() async { if (!_isInitialized) return; - await _nostr.relaysService.disconnectFromRelays(); + await _nostr.services.relays.disconnectFromRelays(); _isInitialized = false; _logger.i('Disconnected from all relays'); } diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 4d64ea77..a955c1cd 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -3,7 +3,6 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; -import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; final appInitializerProvider = FutureProvider((ref) async { @@ -12,8 +11,7 @@ final appInitializerProvider = FutureProvider((ref) async { if (!hasMaster) { await keyManager.generateAndStoreMasterKey(); } - final sessionManager = ref.read(sessionManagerProvider); - await sessionManager.init(); + final mostroRepository = ref.read(mostroRepositoryProvider); await mostroRepository.loadMessages(); @@ -23,7 +21,6 @@ final appInitializerProvider = FutureProvider((ref) async { } }); - Future clearAppData() async { // 1) SharedPreferences final prefs = await SharedPreferences.getInstance(); @@ -32,4 +29,4 @@ Future clearAppData() async { // 2) Flutter Secure Storage const secureStorage = FlutterSecureStorage(); await secureStorage.deleteAll(); -} \ No newline at end of file +} diff --git a/lib/shared/providers/mostro_database_provider.dart b/lib/shared/providers/mostro_database_provider.dart new file mode 100644 index 00000000..0016f799 --- /dev/null +++ b/lib/shared/providers/mostro_database_provider.dart @@ -0,0 +1,17 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sembast/sembast_io.dart'; + +Future openMostroDatabase() async { + final dir = await getApplicationDocumentsDirectory(); + await dir.create(recursive: true); + final dbPath = join(dir.path, 'mostro.db'); + + final db = await databaseFactoryIo.openDatabase(dbPath); + return db; +} + +final mostroDatabaseProvider = Provider((ref) { + throw UnimplementedError(); +}); diff --git a/lib/shared/providers/session_manager_provider.dart b/lib/shared/providers/session_manager_provider.dart index 66a8b0b0..f6f4a59d 100644 --- a/lib/shared/providers/session_manager_provider.dart +++ b/lib/shared/providers/session_manager_provider.dart @@ -1,10 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; -import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'package:mostro_mobile/shared/providers/session_storage_provider.dart'; final sessionManagerProvider = Provider((ref) { final keyManager = ref.read(keyManagerProvider); - final secureStorage = ref.read(secureStorageProvider); - return SessionManager(keyManager, secureStorage); + final sessionStorage = ref.read(sessionStorageProvider); + return SessionManager(keyManager, sessionStorage); }); diff --git a/lib/shared/providers/session_storage_provider.dart b/lib/shared/providers/session_storage_provider.dart new file mode 100644 index 00000000..19166c29 --- /dev/null +++ b/lib/shared/providers/session_storage_provider.dart @@ -0,0 +1,10 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/repositories/session_storage.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; + +final sessionStorageProvider = Provider((ref) { + final keyManager = ref.read(keyManagerProvider); + final database = ref.read(mostroDatabaseProvider); + return SessionStorage(database, keyManager); +}); diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 8cf8f5d0..a89c1ee8 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -22,7 +22,7 @@ class NostrUtils { } static NostrKeyPairs generateKeyPairFromPrivateKey(String privateKey) { - return _instance.keysService + return _instance.services.keys .generateKeyPairFromExistingPrivateKey(privateKey); } @@ -36,19 +36,19 @@ class NostrUtils { // Codificación y decodificación de claves static String encodePrivateKeyToNsec(String privateKey) { - return _instance.keysService.encodePrivateKeyToNsec(privateKey); + return _instance.services.bech32.encodePrivateKeyToNsec(privateKey); } static String decodeNsecKeyToPrivateKey(String nsec) { - return _instance.keysService.decodeNsecKeyToPrivateKey(nsec); + return _instance.services.bech32.decodeNsecKeyToPrivateKey(nsec); } static String encodePublicKeyToNpub(String publicKey) { - return _instance.keysService.encodePublicKeyToNpub(publicKey); + return _instance.services.bech32.encodePublicKeyToNpub(publicKey); } static String decodeNpubKeyToPublicKey(String npub) { - return _instance.keysService.decodeNpubKeyToPublicKey(npub); + return _instance.services.bech32.decodeNpubKeyToPublicKey(npub); } static String nsecToHex(String nsec) { @@ -60,21 +60,21 @@ class NostrUtils { // Operaciones con claves static String derivePublicKey(String privateKey) { - return _instance.keysService.derivePublicKey(privateKey: privateKey); + return _instance.services.keys.derivePublicKey(privateKey: privateKey); } static bool isValidPrivateKey(String privateKey) { - return _instance.keysService.isValidPrivateKey(privateKey); + return _instance.services.keys.isValidPrivateKey(privateKey); } // Firma y verificación static String signMessage(String message, String privateKey) { - return _instance.keysService.sign(privateKey: privateKey, message: message); + return _instance.services.keys.sign(privateKey: privateKey, message: message); } static bool verifySignature( String signature, String message, String publicKey) { - return _instance.keysService + return _instance.services.keys .verify(publicKey: publicKey, message: message, signature: signature); } @@ -98,17 +98,17 @@ class NostrUtils { // Utilidades generales static String decodeBech32(String bech32String) { - final result = _instance.utilsService.decodeBech32(bech32String); + final result = _instance.services.bech32.decodeBech32(bech32String); return result[1]; // Devuelve solo la parte de datos } static String encodeBech32(String hrp, String data) { - return _instance.utilsService.encodeBech32(hrp, data); + return _instance.services.bech32.encodeBech32(hrp, data); } static Future pubKeyFromIdentifierNip05( String internetIdentifier) async { - return await _instance.utilsService + return await _instance.services.utils .pubKeyFromIdentifierNip05(internetIdentifier: internetIdentifier); } diff --git a/lib/shared/widgets/privacy_switch_widget.dart b/lib/shared/widgets/privacy_switch_widget.dart new file mode 100644 index 00000000..f9da1f04 --- /dev/null +++ b/lib/shared/widgets/privacy_switch_widget.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; + +class PrivacySwitch extends StatefulWidget { + final bool initialValue; + final ValueChanged onChanged; + + const PrivacySwitch({ + super.key, + this.initialValue = false, + required this.onChanged, + }); + + @override + State createState() => _PrivacySwitchState(); +} + +class _PrivacySwitchState extends State { + late bool _isFullPrivacy; + + @override + void initState() { + super.initState(); + _isFullPrivacy = widget.initialValue; + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + HeroIcon( + _isFullPrivacy ? HeroIcons.eyeSlash : HeroIcons.eye, + style: HeroIconStyle.outline, + color: AppTheme.cream1, + size: 24, + ), + const SizedBox(width: 8), + // The Switch control. + Switch( + value: _isFullPrivacy, + onChanged: (value) { + setState(() { + _isFullPrivacy = value; + }); + widget.onChanged(value); + }, + activeColor: AppTheme.mostroGreen, + inactiveThumbColor: Colors.grey, + ), + const SizedBox(width: 8), + // A text label that changes based on the switch value. + Text( + _isFullPrivacy ? 'Full Privacy' : 'Normal', + style: const TextStyle(color: Colors.white), + ), + ], + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 23aa2683..e7949dae 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,7 +10,6 @@ import flutter_secure_storage_darwin import local_auth_darwin import path_provider_foundation import shared_preferences_foundation -import sqflite_sqlcipher func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AwesomeNotificationsPlugin.register(with: registry.registrar(forPlugin: "AwesomeNotificationsPlugin")) @@ -18,5 +17,4 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SqfliteSqlCipherPlugin.register(with: registry.registrar(forPlugin: "SqfliteSqlCipherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 74ebb742..0b34619f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -282,10 +282,10 @@ packages: dependency: "direct main" description: name: dart_nostr - sha256: "9adb4c34dfdd3ab811af8d7a22570d34c857afc32ee49140729ceb25c4327666" + sha256: abcf02ee243a156aea494206a9368640a4cffdd458dfdafb92d2fc2f8d883a8f url: "https://pub.dev" source: hosted - version: "8.2.0" + version: "9.0.0" dart_style: dependency: transitive description: @@ -795,7 +795,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -938,6 +938,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + sembast: + dependency: "direct main" + description: + name: sembast + sha256: "6cf9acde19bd88dba9ea8090d3e50725cdc70fc7cf1c117c26e79c257884b04b" + url: "https://pub.dev" + source: hosted + version: "3.8.2" shared_preferences: dependency: "direct main" description: @@ -1071,22 +1079,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" - url: "https://pub.dev" - source: hosted - version: "2.5.4+6" - sqflite_sqlcipher: - dependency: "direct main" - description: - name: sqflite_sqlcipher - sha256: "16033fde6c7d7bd657b71a2bc42332ab02bc8001c3212f502d2e02714e735ec9" - url: "https://pub.dev" - source: hosted - version: "3.1.0+1" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 155b3bda..66366896 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: cupertino_icons: ^1.0.8 http: ^1.2.2 - dart_nostr: ^8.2.0 + dart_nostr: ^9.0.0 qr_flutter: ^4.0.0 heroicons: ^0.10.0 crypto: ^3.0.5 @@ -60,7 +60,6 @@ dependencies: ref: master flutter_secure_storage: ^10.0.0-beta.4 - sqflite_sqlcipher: ^3.1.0+1 go_router: ^14.6.2 bip39: ^1.0.6 flutter_hooks: ^0.20.5 @@ -70,6 +69,8 @@ dependencies: path: ^1.9.0 awesome_notifications: ^0.10.0 palette_generator: ^0.3.3+5 + sembast: ^3.8.2 + path_provider: ^2.1.5 dev_dependencies: flutter_test: From 64d4a270c33f88245468dcd26156f7e7ed038621 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 12 Feb 2025 14:11:43 -0800 Subject: [PATCH 041/149] updated mostro storage to use sembast --- lib/data/repositories/mostro_repository.dart | 43 +++++++------------ ...ory_encrypted.dart => mostro_storage.dart} | 10 ++--- .../providers/mostro_service_provider.dart | 6 +-- .../providers/mostro_storage_provider.dart | 8 ++++ 4 files changed, 30 insertions(+), 37 deletions(-) rename lib/data/repositories/{order_repository_encrypted.dart => mostro_storage.dart} (88%) create mode 100644 lib/shared/providers/mostro_storage_provider.dart diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index 125a6e5b..6bf01ba2 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -1,28 +1,27 @@ import 'dart:async'; -import 'dart:convert'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:logger/logger.dart'; -import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/payload.dart'; import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; class MostroRepository implements OrderRepository { final MostroService _mostroService; - final FlutterSecureStorage _secureStorage; + final MostroStorage _messageStorage; final Map _messages = {}; final Map> _subscriptions = {}; - MostroRepository(this._mostroService, this._secureStorage); + MostroRepository(this._mostroService, this._messageStorage); final _logger = Logger(); @override - Future getOrderById(String orderId) => Future.value(_messages[orderId]); + Future getOrderById(String orderId) => + Future.value(_messages[orderId]); List get allMessages => _messages.values.toList(); @@ -38,7 +37,8 @@ class MostroRepository implements OrderRepository { }, onError: (error) { // Log or handle subscription errors - _logger.e('Error in subscription for session ${session.keyIndex}: $error'); + _logger + .e('Error in subscription for session ${session.keyIndex}: $error'); }, cancelOnError: false, ); @@ -80,34 +80,23 @@ class MostroRepository implements OrderRepository { Future saveMessages() async { for (var m in _messages.values.toList()) { - await _secureStorage.write( - key: '${SecureStorageKeys.message}-${m.id}', - value: jsonEncode(m.toJson())); + await _messageStorage.addOrder(m); } } Future saveMessage(MostroMessage message) async { - await _secureStorage.write( - key: '${SecureStorageKeys.message}-${message.id}', - value: jsonEncode(message.toJson())); + await _messageStorage.addOrder(message); } Future deleteMessage(String messageId) async { _messages.remove(messageId); - await _secureStorage.delete(key: '${SecureStorageKeys.message}-$messageId'); + await _messageStorage.deleteOrder(messageId); } Future loadMessages() async { - final allEntries = await _secureStorage.readAll(); - for (final entry in allEntries.entries) { - if (entry.key.startsWith(SecureStorageKeys.message.value)) { - try { - final msg = MostroMessage.deserialized(entry.value); - _messages[msg.id!] = msg; - } catch (e) { - _logger.e('Error deserializing message for key ${entry.key}: $e'); - } - } + final allEntries = await _messageStorage.getAllOrders(); + for (final entry in allEntries) { + _messages[entry.id!] = entry; } } @@ -126,9 +115,9 @@ class MostroRepository implements OrderRepository { } @override - Future deleteOrder(String orderId) { - // TODO: implement deleteOrder - throw UnimplementedError(); + Future deleteOrder(String orderId) async { + _messages.remove(orderId); + _messageStorage.deleteOrder(orderId); } @override diff --git a/lib/data/repositories/order_repository_encrypted.dart b/lib/data/repositories/mostro_storage.dart similarity index 88% rename from lib/data/repositories/order_repository_encrypted.dart rename to lib/data/repositories/mostro_storage.dart index 04ade39c..a88b0112 100644 --- a/lib/data/repositories/order_repository_encrypted.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -7,15 +7,13 @@ import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; -/// Example (somewhat minimal) repository for storing and retrieving -/// orders in a Sembast database. -class OrderRepositoryEncrypted implements OrderRepository { +class MostroStorage implements OrderRepository { final Logger _logger = Logger(); final Database _database; final StoreRef> _ordersStore = stringMapStoreFactory.store('orders'); - OrderRepositoryEncrypted(this._database); + MostroStorage(this._database); /// Save or update a MostroMessage (with an Order payload) in Sembast @override @@ -24,7 +22,6 @@ class OrderRepositoryEncrypted implements OrderRepository { if (orderId == null) { throw ArgumentError('Cannot save an order with a null message.id'); } - // Convert to JSON so we can store as a Map final jsonMap = message.toJson(); await _ordersStore.record(orderId).put(_database, jsonMap); _logger.i('Order $orderId saved to Sembast'); @@ -74,11 +71,10 @@ class OrderRepositoryEncrypted implements OrderRepository { await _ordersStore.delete(_database); } - /// Example usage: you might have a function to update the status or action Future updateAction(String orderId, Action newAction) async { final record = await _ordersStore.record(orderId).get(_database); if (record == null) { - // no such order + _logger.i("No such order $orderId"); return; } record['order']['action'] = newAction.value; diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index 24fc53ad..7e5d63c3 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -1,9 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; -import 'package:mostro_mobile/shared/providers/storage_providers.dart'; final mostroServiceProvider = Provider((ref) { final sessionStorage = ref.watch(sessionManagerProvider); @@ -13,6 +13,6 @@ final mostroServiceProvider = Provider((ref) { final mostroRepositoryProvider = Provider((ref) { final mostroService = ref.watch(mostroServiceProvider); - final secureStorage = ref.watch(secureStorageProvider); - return MostroRepository(mostroService, secureStorage); + final mostroDatabase = ref.watch(mostroStorageProvider); + return MostroRepository(mostroService, mostroDatabase); }); diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart new file mode 100644 index 00000000..207718c7 --- /dev/null +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; +import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; + +final mostroStorageProvider = Provider((ref) { + final mostroDatabase = ref.watch(mostroDatabaseProvider); + return MostroStorage(mostroDatabase); +}); From 8f941421c781cd670a49db32b2956d1edbb6355f Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 12 Feb 2025 19:18:07 -0800 Subject: [PATCH 042/149] Clear caches --- lib/data/models/mostro_message.dart | 41 +++++++++++---------- lib/data/repositories/mostro_storage.dart | 8 ++-- lib/services/mostro_service.dart | 39 ++++++++++++++++---- lib/shared/providers/app_init_provider.dart | 12 +++++- lib/shared/widgets/mostro_app_bar.dart | 10 +++-- 5 files changed, 75 insertions(+), 35 deletions(-) diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 6f51bd60..08848c07 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -5,43 +5,43 @@ import 'package:mostro_mobile/data/models/payload.dart'; class MostroMessage { String? id; + String? requestId; final Action action; int? tradeIndex; T? _payload; - MostroMessage({required this.action, this.id, T? payload, this.tradeIndex}) + MostroMessage({required this.action, this.requestId, this.id, T? payload, this.tradeIndex}) : _payload = payload; Map toJson() { return { - 'order': { - 'version': Config.mostroVersion, - 'request_id': id, - 'trade_index': tradeIndex, - 'action': action.value, - 'payload': _payload?.toJson(), - }, + 'version': Config.mostroVersion, + 'request_id': requestId, + 'trade_index': tradeIndex, + 'id': id, + 'action': action.value, + 'payload': _payload?.toJson(), }; } factory MostroMessage.fromJson(Map json) { return MostroMessage( - action: Action.fromString(json['order']['action']), - id: json['order']['id'], - tradeIndex: json['order']['trade_index'], - payload: json['order']['payload'] != null ? Payload.fromJson(json['order']['payload']) as T? : null, + action: Action.fromString(json['action']), + requestId: json['id'], + tradeIndex: json['trade_index'], + id: json['id'], + payload: json['payload'] != null + ? Payload.fromJson(json['payload']) as T? + : null, ); } - factory MostroMessage.deserialized(String data) { + factory MostroMessage.deserialized(String json) { try { - final decoded = jsonDecode(data); - final event = decoded[0] as Map; - final order = event['order'] != null - ? event['order'] as Map - : event['cant-do'] != null - ? event['cant-do'] as Map - : throw FormatException('Missing order object'); + final data = jsonDecode(json); + final order = (data is List) + ? data[0] as Map + : data as Map; final action = order['action'] != null ? Action.fromString(order['action']) @@ -55,6 +55,7 @@ class MostroMessage { return MostroMessage( action: action, + requestId: order['request_id'], id: order['id'], payload: payload, tradeIndex: tradeIndex, diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index a88b0112..c983be7b 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -29,7 +29,7 @@ class MostroStorage implements OrderRepository { /// Retrieve an order by ID @override - Future?> getOrderById(String orderId) async { + Future getOrderById(String orderId) async { final record = await _ordersStore.record(orderId).get(_database); if (record == null) return null; try { @@ -37,7 +37,7 @@ class MostroStorage implements OrderRepository { // If the payload is indeed an Order, you can cast or do a check: // final order = msg.getPayload(); // ... - return msg as MostroMessage; + return msg; } catch (e) { _logger.e('Error deserializing order $orderId: $e'); return null; @@ -48,11 +48,11 @@ class MostroStorage implements OrderRepository { @override Future> getAllOrders() async { final records = await _ordersStore.find(_database); - final results = >[]; + final results = []; for (final record in records) { try { final msg = MostroMessage.deserialized(jsonEncode(record.value)); - results.add(msg as MostroMessage); + results.add(msg); } catch (e) { _logger.e('Error deserializing order with key ${record.key}: $e'); } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 170239b6..18abe42b 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -22,12 +22,34 @@ class MostroService { return _nostrService.subscribeToEvents(filter).asyncMap((event) async { final decryptedEvent = await _nostrService.decryptNIP59Event( event, session.tradeKey.private); - final msg = MostroMessage.deserialized(decryptedEvent.content!); - if (session.orderId == null && msg.id != null) { - session.orderId = msg.id; - await _sessionManager.saveSession(session); + + // Check event content is not null + if (decryptedEvent.content == null) { + _logger.i('Event ${decryptedEvent.id} content is null'); + throw FormatException('Event ${decryptedEvent.id} content is null'); + } + + // Deserialize the message content: + final result = jsonDecode(decryptedEvent.content!); + + // The result should be an array of two elements, the first being + // A MostroMessage or CantDo + if (result is! List) { + throw FormatException( + 'Event content ${decryptedEvent.content} should be a List'); + } + + final msgMap = result[0]; + + if (msgMap.containsKey('order')) { + final msg = MostroMessage.fromJson(msgMap['order']); + if (session.orderId == null && msg.id != null) { + session.orderId = msg.id; + await _sessionManager.saveSession(session); + } + return msg; } - return msg; + throw FormatException('Result not found ${decryptedEvent.content}'); }); } @@ -78,7 +100,7 @@ class MostroService { String content; if (!session.fullPrivacy) { order.tradeIndex = session.keyIndex; - final message = order.toJson(); + final message = {'order': order.toJson()}; final serializedEvent = jsonEncode(message['order']); final bytes = utf8.encode(serializedEvent); final digest = sha256.convert(bytes); @@ -86,7 +108,10 @@ class MostroService { final signature = session.tradeKey.sign(hash); content = jsonEncode([message, signature]); } else { - content = jsonEncode([order.toJson(), null]); + content = jsonEncode([ + {'order': order.toJson()}, + null + ]); } final event = await createNIP59Event(content, Config.mostroPubKey, session); await _nostrService.publishEvent(event); diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index a955c1cd..c2782f56 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; @@ -21,12 +23,20 @@ final appInitializerProvider = FutureProvider((ref) async { } }); -Future clearAppData() async { +Future clearAppData(MostroStorage mostroStorage) async { + final logger = Logger(); // 1) SharedPreferences final prefs = await SharedPreferences.getInstance(); await prefs.clear(); + logger.i("Shared Preferences Cleared"); // 2) Flutter Secure Storage const secureStorage = FlutterSecureStorage(); await secureStorage.deleteAll(); + logger.i("Shared Storage Cleared"); + + // 3) MostroStorage + mostroStorage.deleteAllOrders(); + logger.i("Mostro Message Storage cleared"); + } diff --git a/lib/shared/widgets/mostro_app_bar.dart b/lib/shared/widgets/mostro_app_bar.dart index 5d815119..864b8171 100644 --- a/lib/shared/widgets/mostro_app_bar.dart +++ b/lib/shared/widgets/mostro_app_bar.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; -class MostroAppBar extends StatelessWidget implements PreferredSizeWidget { +class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { const MostroAppBar({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return AppBar( backgroundColor: const Color(0xFF1D212C), elevation: 0, @@ -31,7 +34,8 @@ class MostroAppBar extends StatelessWidget implements PreferredSizeWidget { icon: const HeroIcon(HeroIcons.bolt, style: HeroIconStyle.solid, color: Color(0xFF8CC541)), onPressed: () async { - await clearAppData(); + final mostroStorage = ref.watch(mostroStorageProvider); + await clearAppData(mostroStorage); }, ), ], From 64b5293ff498356718d944591a312bb3d5ba654c Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 12 Feb 2025 21:41:12 -0800 Subject: [PATCH 043/149] update tests --- integration_test/new_buy_order_test.dart | 10 +++++----- integration_test/new_sell_order_test.dart | 6 +++--- lib/data/repositories/mostro_storage.dart | 1 - lib/data/repositories/session_storage.dart | 12 ++++++++---- lib/services/mostro_service.dart | 4 ++++ lib/shared/providers/app_init_provider.dart | 5 ++++- lib/shared/widgets/mostro_app_bar.dart | 1 - 7 files changed, 24 insertions(+), 15 deletions(-) diff --git a/integration_test/new_buy_order_test.dart b/integration_test/new_buy_order_test.dart index 3c4a8084..11616ea7 100644 --- a/integration_test/new_buy_order_test.dart +++ b/integration_test/new_buy_order_test.dart @@ -11,7 +11,7 @@ void main() { testWidgets('User creates a new BUY order with VES=100 and premium=1', (tester) async { app.main(); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 1)); // Navigate to the “Add Order” screen final createOrderButton = find.byKey(const Key('createOrderButton')); @@ -91,7 +91,7 @@ void main() { expect(submitButton, findsOneWidget, reason: 'A SUBMIT button is expected'); await tester.tap(submitButton); - await tester.pumpAndSettle(const Duration(seconds: 3)); + await tester.pumpAndSettle(const Duration(seconds: 1)); // The app sends a Nostr “Gift wrap” event with the following content: // { @@ -198,7 +198,7 @@ void main() { reason: 'A SUBMIT button is expected'); await tester.tap(submitButton); await tester.pumpAndSettle(); - await tester.pumpAndSettle(const Duration(seconds: 3)); + await tester.pumpAndSettle(const Duration(seconds: 1)); // The app sends a Nostr “Gift wrap” event with the following content: // { @@ -315,7 +315,7 @@ void main() { reason: 'A SUBMIT button is expected'); await tester.tap(submitButton); await tester.pumpAndSettle(); - await tester.pumpAndSettle(const Duration(seconds: 3)); + await tester.pumpAndSettle(const Duration(seconds: 1)); // The app sends a Nostr “Gift wrap” event with the following content: // { @@ -438,7 +438,7 @@ void main() { reason: 'A SUBMIT button is expected'); await tester.tap(submitButton); await tester.pumpAndSettle(); - await tester.pumpAndSettle(const Duration(seconds: 3)); + await tester.pumpAndSettle(const Duration(seconds: 1)); // The app sends a Nostr “Gift wrap” event with the following content: // { diff --git a/integration_test/new_sell_order_test.dart b/integration_test/new_sell_order_test.dart index 548421d0..9e8af10e 100644 --- a/integration_test/new_sell_order_test.dart +++ b/integration_test/new_sell_order_test.dart @@ -11,7 +11,7 @@ void main() { testWidgets('User creates a new SELL order with VES=100 and premium=1', (tester) async { app.main(); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 1)); // Navigate to the “Add Order” screen final createOrderButton = find.byKey(const Key('createOrderButton')); @@ -91,7 +91,7 @@ void main() { expect(submitButton, findsOneWidget, reason: 'A SUBMIT button is expected'); await tester.tap(submitButton); - await tester.pumpAndSettle(const Duration(seconds: 3)); + await tester.pumpAndSettle(const Duration(seconds: 1)); // The app sends a Nostr “Gift wrap” event with the following content: // { @@ -207,7 +207,7 @@ void main() { reason: 'A SUBMIT button is expected'); await tester.tap(submitButton); await tester.pumpAndSettle(); - await tester.pumpAndSettle(const Duration(seconds: 3)); + await tester.pumpAndSettle(const Duration(seconds: 1)); // The app sends a Nostr “Gift wrap” event with the following content: // { diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index c983be7b..3c74c780 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -5,7 +5,6 @@ import 'package:mostro_mobile/data/repositories/order_repository_interface.dart' import 'package:sembast/sembast.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/models/order.dart'; class MostroStorage implements OrderRepository { final Logger _logger = Logger(); diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index 5777b05d..44674ca5 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -2,6 +2,7 @@ import 'package:sembast/sembast.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/key_manager/key_manager.dart'; +import 'package:sembast/utils/value_utils.dart'; class SessionStorage { final Database _database; @@ -61,7 +62,8 @@ class SessionStorage { /// Finds and deletes sessions considered expired, returning a list of deleted IDs. /// (Keeps domain logic here for simplicity; you could also move it into the Manager.) - Future> deleteExpiredSessions(int sessionExpirationHours, int maxBatchSize) async { + Future> deleteExpiredSessions( + int sessionExpirationHours, int maxBatchSize) async { final now = DateTime.now(); final records = await _store.find(_database); final removedIds = []; @@ -99,9 +101,11 @@ class SessionStorage { // Re-get masterKey (potentially from secure storage/caching) final masterKey = await _keyManager.getMasterKey(); - map['trade_key'] = tradeKey; - map['master_key'] = masterKey; + var clone = cloneMap(map); - return Session.fromJson(map); + clone['trade_key'] = tradeKey; + clone['master_key'] = masterKey; + + return Session.fromJson(clone); } } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 18abe42b..0ac9a673 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -49,6 +49,10 @@ class MostroService { } return msg; } + + if (msgMap.containsKey('cant-do')) { + // throw an error + } throw FormatException('Result not found ${decryptedEvent.content}'); }); } diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index c2782f56..2121fe6d 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -5,6 +5,7 @@ import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; final appInitializerProvider = FutureProvider((ref) async { @@ -14,6 +15,9 @@ final appInitializerProvider = FutureProvider((ref) async { await keyManager.generateAndStoreMasterKey(); } + final sessionManager = ref.read(sessionManagerProvider); + await sessionManager.init(); + final mostroRepository = ref.read(mostroRepositoryProvider); await mostroRepository.loadMessages(); @@ -38,5 +42,4 @@ Future clearAppData(MostroStorage mostroStorage) async { // 3) MostroStorage mostroStorage.deleteAllOrders(); logger.i("Mostro Message Storage cleared"); - } diff --git a/lib/shared/widgets/mostro_app_bar.dart b/lib/shared/widgets/mostro_app_bar.dart index 864b8171..f2c230d8 100644 --- a/lib/shared/widgets/mostro_app_bar.dart +++ b/lib/shared/widgets/mostro_app_bar.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; -import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { From 86d2d3912d83dc15868ec20bb1e697120382a4ef Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 13 Feb 2025 20:20:48 -0800 Subject: [PATCH 044/149] Added Mostro Instance Screen and updated OrderNotifier strings --- lib/app/app_routes.dart | 2 +- lib/app/app_settings.dart | 12 -- lib/data/models/nostr_event.dart | 8 +- lib/data/models/session.dart | 1 + .../repositories/open_orders_repository.dart | 12 +- .../mostro}/mostro_instance.dart | 47 +++--- .../mostro/mostro_instance_provider.dart | 4 + lib/features/mostro/mostro_screen.dart | 153 ++++++++++++++++++ .../mostro/screens/mostro_screen.dart | 70 -------- .../notfiers/abstract_order_notifier.dart | 20 ++- lib/features/settings/settings.dart | 11 ++ .../settings/settings_controller.dart} | 6 +- .../settings_controller_provider.dart | 8 + .../take_order/screens/take_order_screen.dart | 41 +++-- lib/services/mostro_service.dart | 6 +- .../app_settings_controller_provider.dart | 8 - lib/shared/widgets/mostro_app_drawer.dart | 2 +- 17 files changed, 261 insertions(+), 150 deletions(-) delete mode 100644 lib/app/app_settings.dart rename lib/{data/models => features/mostro}/mostro_instance.dart (66%) create mode 100644 lib/features/mostro/mostro_instance_provider.dart create mode 100644 lib/features/mostro/mostro_screen.dart delete mode 100644 lib/features/mostro/screens/mostro_screen.dart create mode 100644 lib/features/settings/settings.dart rename lib/{shared/notifiers/app_settings_controller.dart => features/settings/settings_controller.dart} (73%) create mode 100644 lib/features/settings/settings_controller_provider.dart delete mode 100644 lib/shared/providers/app_settings_controller_provider.dart diff --git a/lib/app/app_routes.dart b/lib/app/app_routes.dart index 7aa59477..6c388c7a 100644 --- a/lib/app/app_routes.dart +++ b/lib/app/app_routes.dart @@ -7,7 +7,7 @@ import 'package:mostro_mobile/features/auth/screens/welcome_screen.dart'; import 'package:mostro_mobile/features/messages/screens/messages_list_screen.dart'; import 'package:mostro_mobile/features/home/screens/home_screen.dart'; import 'package:mostro_mobile/features/key_manager/key_management_screen.dart'; -import 'package:mostro_mobile/features/mostro/screens/mostro_screen.dart'; +import 'package:mostro_mobile/features/mostro/mostro_screen.dart'; import 'package:mostro_mobile/features/trades/screens/trades_screen.dart'; import 'package:mostro_mobile/features/relays/relays_screen.dart'; import 'package:mostro_mobile/features/take_order/screens/add_lightning_invoice_screen.dart'; diff --git a/lib/app/app_settings.dart b/lib/app/app_settings.dart deleted file mode 100644 index 312589c2..00000000 --- a/lib/app/app_settings.dart +++ /dev/null @@ -1,12 +0,0 @@ -class AppSettings { - final bool fullPrivacyMode; - - AppSettings({required this.fullPrivacyMode}); - - factory AppSettings.intial() => AppSettings(fullPrivacyMode: false); - - AppSettings copyWith({required bool fullPrivacyMode}) { - return AppSettings(fullPrivacyMode: fullPrivacyMode); - } - -} diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index 5e88ce97..e8a1d6ae 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -30,13 +30,7 @@ extension NostrEventExtensions on NostrEvent { String? get bond => _getTagValue('bond'); String? get expiration => _timeAgo(_getTagValue('expiration')); String? get platform => _getTagValue('y'); - Order? get document { - final jsonString = _getTagValue('z'); - if (jsonString != null) { - return Order.fromJson(jsonDecode(jsonString)); - } - return null; - } + String get type => _getTagValue('z')!; String? _getTagValue(String key) { final tag = tags?.firstWhere((t) => t[0] == key, orElse: () => []); diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 76a1ff9b..dbd08021 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -25,6 +25,7 @@ class Session { 'event_id': orderId, 'key_index': keyIndex, 'full_privacy': fullPrivacy, + 'trade_key': tradeKey.private, }; factory Session.fromJson(Map json) { diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index ee175ed2..cc73eacd 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -11,12 +11,16 @@ const orderFilterDurationHours = 48; class OpenOrdersRepository implements OrderRepository { final NostrService _nostrService; + NostrEvent? _mostroInstance; + final StreamController> _eventStreamController = StreamController.broadcast(); final Map _events = {}; final _logger = Logger(); StreamSubscription? _subscription; + NostrEvent? get mostroInstance => _mostroInstance; + OpenOrdersRepository(this._nostrService); /// Subscribes to events matching the given filter. @@ -31,8 +35,12 @@ class OpenOrdersRepository implements OrderRepository { ); _subscription = _nostrService.subscribeToEvents(filter).listen((event) { - _events[event.orderId!] = event; - _eventStreamController.add(_events.values.toList()); + if (event.type == 'order') { + _events[event.orderId!] = event; + _eventStreamController.add(_events.values.toList()); + } else if (event.type == 'info') { + _mostroInstance = event; + } }, onError: (error) { _logger.e('Error in order subscription: $error'); }); diff --git a/lib/data/models/mostro_instance.dart b/lib/features/mostro/mostro_instance.dart similarity index 66% rename from lib/data/models/mostro_instance.dart rename to lib/features/mostro/mostro_instance.dart index d2e0c100..ac3b7263 100644 --- a/lib/data/models/mostro_instance.dart +++ b/lib/features/mostro/mostro_instance.dart @@ -2,6 +2,7 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; class MostroInstance { final String mostroVersion; + final String commitHash; final int maxOrderAmount; final int minOrderAmount; final int expirationHours; @@ -13,29 +14,33 @@ class MostroInstance { final int invoiceExpirationWindow; MostroInstance( - this.mostroVersion, - this.maxOrderAmount, - this.minOrderAmount, - this.expirationHours, - this.expirationSeconds, - this.fee, - this.pow, - this.holdInvoiceExpirationWindow, - this.holdInvoiceCltvDelta, - this.invoiceExpirationWindow); + this.mostroVersion, + this.commitHash, + this.maxOrderAmount, + this.minOrderAmount, + this.expirationHours, + this.expirationSeconds, + this.fee, + this.pow, + this.holdInvoiceExpirationWindow, + this.holdInvoiceCltvDelta, + this.invoiceExpirationWindow, + ); factory MostroInstance.fromEvent(NostrEvent event) { return MostroInstance( - event.mostroVersion, - event.maxOrderAmount, - event.minOrderAmount, - event.expirationHours, - event.expirationSeconds, - event.fee, - event.pow, - event.holdInvoiceExpirationWindow, - event.holdInvoiceCltvDelta, - event.invoiceExpirationWindow); + event.mostroVersion, + event.commitHash, + event.maxOrderAmount, + event.minOrderAmount, + event.expirationHours, + event.expirationSeconds, + event.fee, + event.pow, + event.holdInvoiceExpirationWindow, + event.holdInvoiceCltvDelta, + event.invoiceExpirationWindow, + ); } } @@ -45,7 +50,9 @@ extension MostroInstanceExtensions on NostrEvent { return (tag != null && tag.length > 1) ? tag[1] : null; } + String get pubKey => _getTagValue('d')!; String get mostroVersion => _getTagValue('mostro_version')!; + String get commitHash => _getTagValue('mostro_commit_hash')!; int get maxOrderAmount => int.parse(_getTagValue('max_order_amount')!); int get minOrderAmount => int.parse(_getTagValue('min_order_amount')!); int get expirationHours => int.parse(_getTagValue('expiration_hours')!); diff --git a/lib/features/mostro/mostro_instance_provider.dart b/lib/features/mostro/mostro_instance_provider.dart new file mode 100644 index 00000000..f411fec2 --- /dev/null +++ b/lib/features/mostro/mostro_instance_provider.dart @@ -0,0 +1,4 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final mostroInstanceProvider = StateProvider((ref) => null); diff --git a/lib/features/mostro/mostro_screen.dart b/lib/features/mostro/mostro_screen.dart new file mode 100644 index 00000000..0cb9d59b --- /dev/null +++ b/lib/features/mostro/mostro_screen.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; +import 'package:mostro_mobile/shared/widgets/custom_card.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; + +class MostroScreen extends ConsumerWidget { + const MostroScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Read the current MostroInstance (here represented by a NostrEvent) + final nostrEvent = ref.watch(orderRepositoryProvider).mostroInstance; + + // For messages, assume you have a provider that holds a list of MostroMessage objects. + final mostroMessages = ref.watch(mostroRepositoryProvider).allMessages; + + return nostrEvent == null + ? Scaffold( + backgroundColor: AppTheme.dark1, + body: const Center(child: CircularProgressIndicator()), + ) + : Scaffold( + backgroundColor: AppTheme.dark1, + appBar: const MostroAppBar(), + drawer: const MostroAppDrawer(), + body: RefreshIndicator( + onRefresh: () async { + // Trigger a refresh of your providers + //ref.refresh(mostroInstanceProvider); + //ref.refresh(mostroMessagesProvider); + }, + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.dark2, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + const SizedBox(height: 24), + Center( + child: CircleAvatar( + radius: 50, + backgroundColor: Colors.grey, + foregroundImage: + AssetImage('assets/images/launcher-icon.png'), + ), + ), + + Padding( + padding: const EdgeInsets.all(16.0), + child: _buildInstanceDetails( + MostroInstance.fromEvent(nostrEvent)), + ), + // List of messages + Expanded( + child: ListView.builder( + itemCount: mostroMessages.length, + itemBuilder: (context, index) { + final message = mostroMessages[index]; + return _buildMessageTile(message); + }, + ), + ), + const BottomNavBar(), + ], + ), + ), + ), + ); + } + + /// Builds the header displaying details from the MostroInstance. + Widget _buildInstanceDetails(MostroInstance instance) { + return CustomCard( + color: AppTheme.dark1, + padding: EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Version: ${instance.mostroVersion}', + style: GoogleFonts.robotoCondensed( + color: AppTheme.cream1, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Commit Hash: ${instance.commitHash}', + ), + const SizedBox(height: 4), + Text( + 'Max Order Amount: ${instance.maxOrderAmount}', + ), + const SizedBox(height: 4), + Text( + 'Min Order Amount: ${instance.minOrderAmount}', + ), + const SizedBox(height: 4), + Text( + 'Expiration Hours: ${instance.expirationHours}', + ), + const SizedBox(height: 4), + Text( + 'Expiration Seconds: ${instance.expirationSeconds}', + ), + const SizedBox(height: 4), + Text( + 'Fee: ${instance.fee}', + ), + const SizedBox(height: 4), + Text( + 'Proof of Work: ${instance.pow}', + ), + const SizedBox(height: 4), + Text( + 'Hold Invoice Expiration Window: ${instance.holdInvoiceExpirationWindow}', + ), + const SizedBox(height: 4), + Text( + 'Hold Invoice CLTV Delta: ${instance.holdInvoiceCltvDelta}', + ), + const SizedBox(height: 4), + Text( + 'Invoice Expiration Windo: ${instance.invoiceExpirationWindow}', + ), + const SizedBox(height: 4), + ], + )); + } + + /// Builds a simple ListTile to display an individual MostroMessage. + Widget _buildMessageTile(MostroMessage message) { + return ListTile( + title: Text( + 'Order Type: ${message.action}', + ), + subtitle: Text( + 'Order Id: ${message.payload?.toJson()}', + ), + ); + } +} diff --git a/lib/features/mostro/screens/mostro_screen.dart b/lib/features/mostro/screens/mostro_screen.dart deleted file mode 100644 index 9fad43b4..00000000 --- a/lib/features/mostro/screens/mostro_screen.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/features/trades/providers/trades_provider.dart'; -import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; -import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; -import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; - -class MostroScreen extends ConsumerWidget { - const MostroScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final orderBookStateAsync = ref.watch(tradesProvider); - - return orderBookStateAsync.when( - data: (orderBookState) { - return Scaffold( - backgroundColor: AppTheme.dark1, - appBar: const MostroAppBar(), - drawer: const MostroAppDrawer(), - body: RefreshIndicator( - onRefresh: () async { - //await orderBookNotifier.refresh(); - }, - child: Container( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), - decoration: BoxDecoration( - color: AppTheme.dark2, - borderRadius: BorderRadius.circular(20), - ), - child: Column( - children: [ - Padding( - padding: EdgeInsets.all(16.0), - child: Text( - 'Mostro', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - fontFamily: GoogleFonts.robotoCondensed().fontFamily, - ), - ), - ), - const BottomNavBar(), - ], - ), - ), - ), - ); - }, - loading: () => const Scaffold( - backgroundColor: AppTheme.dark1, - body: Center(child: CircularProgressIndicator()), - ), - error: (error, stack) => Scaffold( - backgroundColor: AppTheme.dark1, - body: Center( - child: Text( - 'Error: $error', - style: const TextStyle(color: AppTheme.cream1), - ), - ), - ), - ); - } - -} diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 265e4be9..94ff6e41 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -8,6 +8,8 @@ import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; class AbstractOrderNotifier extends StateNotifier { final MostroRepository orderRepository; @@ -41,6 +43,7 @@ class AbstractOrderNotifier extends StateNotifier { void handleOrderUpdate() { final navProvider = ref.read(navigationProvider.notifier); final notifProvider = ref.read(notificationProvider.notifier); + final mostroInstance = ref.read(orderRepositoryProvider).mostroInstance; switch (state.action) { case Action.addInvoice: @@ -73,15 +76,26 @@ class AbstractOrderNotifier extends StateNotifier { break; case Action.waitingSellerToPay: navProvider.go('/'); - notifProvider.showInformation(state.action, values: {'id': state.id}); + notifProvider.showInformation(state.action, values: { + 'id': state.id, + 'expiration_seconds': mostroInstance?.expirationSeconds + }); break; case Action.waitingBuyerInvoice: - notifProvider.showInformation(state.action); + notifProvider.showInformation(state.action, + values: {'expiration_seconds': mostroInstance?.expirationSeconds}); break; case Action.buyerTookOrder: - notifProvider.showInformation(state.action); + final order = state.getPayload(); + notifProvider.showInformation(state.action, values: { + 'buyer_npub': order?.masterBuyerPubkey, + 'fiat_code': order?.fiatCode, + 'fiat_amount': order?.fiatAmount, + 'payment_method': order?.paymentMethod, + }); break; case Action.fiatSentOk: + case Action.holdInvoicePaymentSettled: case Action.rate: case Action.rateReceived: diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart new file mode 100644 index 00000000..f7c5ba67 --- /dev/null +++ b/lib/features/settings/settings.dart @@ -0,0 +1,11 @@ +class Settings { + final bool fullPrivacyMode; + + Settings({required this.fullPrivacyMode}); + + factory Settings.intial() => Settings(fullPrivacyMode: false); + + Settings copyWith({required bool fullPrivacyMode}) { + return Settings(fullPrivacyMode: fullPrivacyMode); + } +} diff --git a/lib/shared/notifiers/app_settings_controller.dart b/lib/features/settings/settings_controller.dart similarity index 73% rename from lib/shared/notifiers/app_settings_controller.dart rename to lib/features/settings/settings_controller.dart index 50b1e129..18ab39cc 100644 --- a/lib/shared/notifiers/app_settings_controller.dart +++ b/lib/features/settings/settings_controller.dart @@ -1,12 +1,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/app/app_settings.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; import 'package:mostro_mobile/shared/providers/storage_providers.dart'; -class AppSettingsController extends StateNotifier { +class SettingsController extends StateNotifier { final Ref ref; - AppSettingsController(this.ref) : super(AppSettings.intial()); + SettingsController(this.ref) : super(Settings.intial()); Future loadSettings() async { final prefs = ref.read(sharedPreferencesProvider); diff --git a/lib/features/settings/settings_controller_provider.dart b/lib/features/settings/settings_controller_provider.dart new file mode 100644 index 00000000..e9c9a473 --- /dev/null +++ b/lib/features/settings/settings_controller_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_controller.dart'; + +final settingsControllerProvider = + StateNotifierProvider((ref) { + return SettingsController(ref); +}); diff --git a/lib/features/take_order/screens/take_order_screen.dart b/lib/features/take_order/screens/take_order_screen.dart index 17558df9..b2ce7a93 100644 --- a/lib/features/take_order/screens/take_order_screen.dart +++ b/lib/features/take_order/screens/take_order_screen.dart @@ -35,11 +35,9 @@ class TakeOrderScreen extends ConsumerWidget { loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => Center(child: Text('Error: $error')), data: (order) { - // If order is null => show "Order not found" if (order == null) { - return const Center(child: Text('Order not found')); + return Center(child: Text('Order $orderId not found')); } - // Build the main UI with the order return SingleChildScrollView( padding: const EdgeInsets.all(16.0), @@ -52,12 +50,11 @@ class TakeOrderScreen extends ConsumerWidget { const SizedBox(height: 16), _buildSellerAmount(ref, order), const SizedBox(height: 16), - // Exchange rate widget - if (order.currency != null) - ExchangeRateWidget(currency: order.currency!), - const SizedBox(height: 16), - _buildBuyerAmount(int.tryParse(order.amount!)), + ExchangeRateWidget(currency: order.currency!), const SizedBox(height: 16), + if ((orderType == OrderType.sell && order.amount != '0') || + order.fiatAmount.maximum != null) + _buildBuyerAmount(int.tryParse(order.amount!)), _buildLnAddress(), const SizedBox(height: 16), _buildActionButtons(context, ref, order.orderId), @@ -106,19 +103,19 @@ class TakeOrderScreen extends ConsumerWidget { } Widget _buildBuyerAmount(int? amount) { - final safeAmount = amount ?? 0; - return CustomCard( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CurrencyTextField(controller: _satsAmountController, label: 'Sats'), - const SizedBox(height: 8), - Text('\$ $safeAmount', style: const TextStyle(color: AppTheme.grey2)), - const SizedBox(height: 24), - ], + return Column(children: [ + CustomCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CurrencyTextField(controller: _satsAmountController, label: 'Fiat'), + const SizedBox(height: 8), + ], + ), ), - ); + const SizedBox(height: 16), + ]); } Widget _buildLnAddress() { @@ -176,8 +173,8 @@ class TakeOrderScreen extends ConsumerWidget { await orderDetailsNotifier.takeBuyOrder(realOrderId, satsAmount); } else { final lndAddress = _lndAddressController.text.trim(); - await orderDetailsNotifier.takeSellOrder( - realOrderId, satsAmount, lndAddress.isEmpty ? null : lndAddress); + await orderDetailsNotifier.takeSellOrder(realOrderId, satsAmount, + lndAddress.isEmpty ? null : lndAddress); } // Could also pass the LN address if your method expects it }, style: ElevatedButton.styleFrom( diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 0ac9a673..5be8366e 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -4,6 +4,7 @@ import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/app/config.dart'; +import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/session.dart'; @@ -51,7 +52,10 @@ class MostroService { } if (msgMap.containsKey('cant-do')) { - // throw an error + final msg = MostroMessage.fromJson(msgMap['cant-do']); + final cantdo = msg.getPayload(); + _logger.e('Can\'t Do: ${cantdo?.cantDo}'); + return msg; } throw FormatException('Result not found ${decryptedEvent.content}'); }); diff --git a/lib/shared/providers/app_settings_controller_provider.dart b/lib/shared/providers/app_settings_controller_provider.dart deleted file mode 100644 index c4645899..00000000 --- a/lib/shared/providers/app_settings_controller_provider.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/app/app_settings.dart'; -import 'package:mostro_mobile/shared/notifiers/app_settings_controller.dart'; - -final appSettingsControllerProvider = - StateNotifierProvider((ref) { - return AppSettingsController(ref); -}); \ No newline at end of file diff --git a/lib/shared/widgets/mostro_app_drawer.dart b/lib/shared/widgets/mostro_app_drawer.dart index 181f7897..007a1b0f 100644 --- a/lib/shared/widgets/mostro_app_drawer.dart +++ b/lib/shared/widgets/mostro_app_drawer.dart @@ -16,7 +16,7 @@ class MostroAppDrawer extends StatelessWidget { decoration: BoxDecoration( color: AppTheme.dark1, image: const DecorationImage( - image: AssetImage("assets/images/mostro-icons.png"), + image: AssetImage('assets/images/mostro-icons.png'), fit: BoxFit.scaleDown)), child: Stack( children: [ From 90436bbf2fe518afac02f6525dc5f4de511c8562 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 13 Feb 2025 23:05:05 -0800 Subject: [PATCH 045/149] Add QR Code Reader to Lightning Invoice screen --- ios/Runner/Info.plist | 94 ++++++------ lib/features/mostro/mostro_screen.dart | 2 +- .../screens/add_lightning_invoice_screen.dart | 136 +++++------------- .../take_order/screens/order_screen.dart | 123 ++++++++++++++++ .../take_order/screens/take_order_screen.dart | 29 ++-- .../providers/mostro_database_provider.dart | 2 +- .../widgets/lightning_invoice_input.dart | 124 ++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 8 ++ pubspec.yaml | 1 + 10 files changed, 357 insertions(+), 164 deletions(-) create mode 100644 lib/features/take_order/screens/order_screen.dart create mode 100644 lib/shared/widgets/lightning_invoice_input.dart diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index ef751be7..6d1cc19e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,49 +1,51 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Mostro Mobile - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - mostro_mobile - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Mostro Mobile + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + mostro_mobile + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + NSCameraUsageDescription + This app needs camera access to scan QR codes + + \ No newline at end of file diff --git a/lib/features/mostro/mostro_screen.dart b/lib/features/mostro/mostro_screen.dart index 0cb9d59b..69842ad2 100644 --- a/lib/features/mostro/mostro_screen.dart +++ b/lib/features/mostro/mostro_screen.dart @@ -132,7 +132,7 @@ class MostroScreen extends ConsumerWidget { ), const SizedBox(height: 4), Text( - 'Invoice Expiration Windo: ${instance.invoiceExpirationWindow}', + 'Invoice Expiration Window: ${instance.invoiceExpirationWindow}', ), const SizedBox(height: 4), ], diff --git a/lib/features/take_order/screens/add_lightning_invoice_screen.dart b/lib/features/take_order/screens/add_lightning_invoice_screen.dart index 420dfa3c..526d8051 100644 --- a/lib/features/take_order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/add_lightning_invoice_screen.dart @@ -7,6 +7,7 @@ import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/widgets/lightning_invoice_input.dart'; class AddLightningInvoiceScreen extends ConsumerStatefulWidget { final String orderId; @@ -27,7 +28,6 @@ class _AddLightningInvoiceScreenState @override void initState() { super.initState(); - // Kick off async load from OrderRepository final orderRepo = ref.read(orderRepositoryProvider); _orderFuture = orderRepo.getOrderById(widget.orderId); } @@ -41,10 +41,8 @@ class _AddLightningInvoiceScreenState future: _orderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - // Still loading return const Center(child: CircularProgressIndicator()); } else if (snapshot.hasError) { - // Show error message return Center( child: Text( 'Failed to load order: ${snapshot.error}', @@ -52,7 +50,6 @@ class _AddLightningInvoiceScreenState ), ); } else { - // Data is loaded (or null if not found) final order = snapshot.data; if (order == null) { return const Center( @@ -63,106 +60,47 @@ class _AddLightningInvoiceScreenState ); } - // Now we have the order, we can safely reference order.amount, etc. - final amount = order.fiatAmount; - + final amount = order.amount; return CustomCard( padding: const EdgeInsets.all(16), child: Material( - color: Colors.transparent, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Please enter a Lightning Invoice for $amount sats:", - style: TextStyle(color: AppTheme.cream1, fontSize: 16), - ), - const SizedBox(height: 8), - TextFormField( - controller: invoiceController, - style: const TextStyle(color: AppTheme.cream1), - decoration: InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - labelText: "Lightning Invoice", - labelStyle: const TextStyle(color: AppTheme.grey2), - hintText: "Enter invoice here", - hintStyle: const TextStyle(color: AppTheme.grey2), - filled: true, - fillColor: AppTheme.dark1, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () async { - // Cancel the order - final orderRepo = ref.read(orderRepositoryProvider); - try { - await orderRepo.deleteOrder(order.id!); - if (!mounted) return; - context.go('/'); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Failed to cancel order: ${e.toString()}'), - ), - ); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - ), - child: const Text('CANCEL'), - ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton( - onPressed: () async { - final invoice = invoiceController.text.trim(); - if (invoice.isNotEmpty) { - final orderRepo = ref.read(orderRepositoryProvider); - try { - // Typically you'd do something like - // order.buyerInvoice = invoice; - // orderRepo.updateOrder(order) - // or a specialized method orderRepo.sendInvoice - // For this example, let's just do an "update" - - - // If you want to navigate away or confirm - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: - Text('Lightning Invoice updated!'), - ), - ); - context.go('/'); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Failed to update invoice: ${e.toString()}'), - ), - ); - } - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, + color: AppTheme.dark1, + child: Padding( + padding: const EdgeInsets.all(16), + child: LightningInvoiceInput( + controller: invoiceController, + onSubmit: () async { + final invoice = invoiceController.text.trim(); + if (invoice.isNotEmpty) { + final orderRepo = ref.read(orderRepositoryProvider); + try { + // Here you would call your method to send or update the invoice. + + context.go('/'); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update invoice: ${e.toString()}'), ), - child: const Text('SUBMIT'), + ); + } + } + }, + onCancel: () async { + final orderRepo = ref.read(orderRepositoryProvider); + try { + await orderRepo.deleteOrder(order.id!); + if (!mounted) return; + context.go('/'); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to cancel order: ${e.toString()}'), ), - ), - ], - ), - ], + ); + } + }, + ), ), ), ); diff --git a/lib/features/take_order/screens/order_screen.dart b/lib/features/take_order/screens/order_screen.dart new file mode 100644 index 00000000..7d329e06 --- /dev/null +++ b/lib/features/take_order/screens/order_screen.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/features/take_order/providers/order_notifier_providers.dart'; + +class TakeOrderScreen extends ConsumerStatefulWidget { + final String orderId; + final OrderType orderType; + + const TakeOrderScreen({ + super.key, + required this.orderId, + required this.orderType, + }); + + @override + ConsumerState createState() => _TakeOrderScreenState(); +} + +class _TakeOrderScreenState extends ConsumerState { + bool _isLoading = false; + + @override + void initState() { + super.initState(); + // Listen to notifier state changes: + ref.listen( + widget.orderType == OrderType.buy + ? takeBuyOrderNotifierProvider(widget.orderId) + : takeSellOrderNotifierProvider(widget.orderId), + (previous, next) { + // If we’re waiting for a response and the state changes from our initial state, + // then navigate accordingly. + if (_isLoading && previous?.action == (widget.orderType == OrderType.buy ? actions.Action.takeBuy : actions.Action.takeSell)) { + setState(() { + _isLoading = false; + }); + // For example, if next.action is now Action.payInvoice, + // navigate to the Lightning Invoice input screen: + if (next.action == actions.Action.payInvoice) { + context.go('/pay_lightning_invoice', extra: widget.orderId); + } + // Or, if you expect a different screen (e.g., for a buyer, to create an invoice), + // adjust the navigation accordingly. + } + }, + ); + } + + @override + Widget build(BuildContext context) { + // If we are in loading state, show a simple loading screen. + if (_isLoading) { + return Scaffold( + appBar: OrderAppBar(title: 'Processing Order'), + backgroundColor: AppTheme.dark1, + body: const Center( + child: CircularProgressIndicator(), + ), + ); + } + + // Otherwise, build the normal UI. (For brevity, this example uses a simple placeholder.) + return Scaffold( + appBar: OrderAppBar( + title: widget.orderType == OrderType.buy ? 'BUY ORDER' : 'SELL ORDER'), + backgroundColor: AppTheme.dark1, + body: Center( + child: ElevatedButton( + onPressed: () async { + final confirmed = await _showConfirmationDialog(); + if (confirmed) { + setState(() { + _isLoading = true; + }); + // Depending on order type, call the appropriate notifier method. + final notifier = widget.orderType == OrderType.buy + ? ref.read(takeBuyOrderNotifierProvider(widget.orderId).notifier) + : ref.read(takeSellOrderNotifierProvider(widget.orderId).notifier); + // Pass along any required parameters (e.g., fiat amount or LN address) as needed. + // Here we assume null values for simplicity. + if (widget.orderType == OrderType.buy) { + await notifier.takeBuyOrder(widget.orderId, null); + } else { + await notifier.takeSellOrder(widget.orderId, null, null); + } + // The ref.listen above will catch the state update and trigger navigation. + } + }, + child: const Text('CONTINUE'), + ), + ), + ); + } + + Future _showConfirmationDialog() async { + return await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Confirm Order'), + content: const Text('Do you really want to take this order?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Confirm'), + ), + ], + ); + }, + ) ?? + false; + } +} diff --git a/lib/features/take_order/screens/take_order_screen.dart b/lib/features/take_order/screens/take_order_screen.dart index b2ce7a93..261a1ff7 100644 --- a/lib/features/take_order/screens/take_order_screen.dart +++ b/lib/features/take_order/screens/take_order_screen.dart @@ -17,14 +17,13 @@ import 'package:mostro_mobile/features/take_order/providers/order_notifier_provi class TakeOrderScreen extends ConsumerWidget { final String orderId; final OrderType orderType; - final TextEditingController _satsAmountController = TextEditingController(); + final TextEditingController _fiatAmountController = TextEditingController(); final TextEditingController _lndAddressController = TextEditingController(); TakeOrderScreen({super.key, required this.orderId, required this.orderType}); @override Widget build(BuildContext context, WidgetRef ref) { - // 1) Watch asynchronous order fetch final orderAsyncValue = ref.watch(eventProvider(orderId)); return Scaffold( @@ -57,7 +56,7 @@ class TakeOrderScreen extends ConsumerWidget { _buildBuyerAmount(int.tryParse(order.amount!)), _buildLnAddress(), const SizedBox(height: 16), - _buildActionButtons(context, ref, order.orderId), + _buildActionButtons(context, ref, order.orderId!), ], ), ); @@ -109,7 +108,7 @@ class TakeOrderScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CurrencyTextField(controller: _satsAmountController, label: 'Fiat'), + CurrencyTextField(controller: _fiatAmountController, label: 'Fiat'), const SizedBox(height: 8), ], ), @@ -142,13 +141,11 @@ class TakeOrderScreen extends ConsumerWidget { } Widget _buildActionButtons( - BuildContext context, WidgetRef ref, String? orderId) { - // If there's no orderId, hide the button or handle it - final realOrderId = orderId ?? ''; + BuildContext context, WidgetRef ref, String orderId) { - final orderDetailsNotifier = orderType == OrderType.sell - ? ref.read(takeSellOrderNotifierProvider(realOrderId).notifier) - : ref.read(takeBuyOrderNotifierProvider(realOrderId).notifier); + final orderDetailsNotifier = (orderType == OrderType.sell) + ? ref.read(takeSellOrderNotifierProvider(orderId).notifier) + : ref.read(takeBuyOrderNotifierProvider(orderId).notifier); return Row( mainAxisAlignment: MainAxisAlignment.end, @@ -165,17 +162,15 @@ class TakeOrderScreen extends ConsumerWidget { const SizedBox(width: 16), ElevatedButton( onPressed: () async { - // Possibly pass the LN address or sats from the text fields - final satsText = _satsAmountController.text; - // Convert satsText to int if needed - final satsAmount = int.tryParse(satsText); + final fiatAmount = int.tryParse(_fiatAmountController.text.trim()); + if (orderType == OrderType.buy) { - await orderDetailsNotifier.takeBuyOrder(realOrderId, satsAmount); + await orderDetailsNotifier.takeBuyOrder(orderId, fiatAmount); } else { final lndAddress = _lndAddressController.text.trim(); - await orderDetailsNotifier.takeSellOrder(realOrderId, satsAmount, + await orderDetailsNotifier.takeSellOrder(orderId, fiatAmount, lndAddress.isEmpty ? null : lndAddress); - } // Could also pass the LN address if your method expects it + } }, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.mostroGreen, diff --git a/lib/shared/providers/mostro_database_provider.dart b/lib/shared/providers/mostro_database_provider.dart index 0016f799..65d95172 100644 --- a/lib/shared/providers/mostro_database_provider.dart +++ b/lib/shared/providers/mostro_database_provider.dart @@ -13,5 +13,5 @@ Future openMostroDatabase() async { } final mostroDatabaseProvider = Provider((ref) { - throw UnimplementedError(); + throw UnimplementedError(); }); diff --git a/lib/shared/widgets/lightning_invoice_input.dart b/lib/shared/widgets/lightning_invoice_input.dart new file mode 100644 index 00000000..1b05af86 --- /dev/null +++ b/lib/shared/widgets/lightning_invoice_input.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class LightningInvoiceInput extends StatefulWidget { + final TextEditingController controller; + final VoidCallback onSubmit; + final VoidCallback onCancel; + + const LightningInvoiceInput({ + super.key, + required this.controller, + required this.onSubmit, + required this.onCancel, + }); + + @override + State createState() => _LightningInvoiceInputState(); +} + +class _LightningInvoiceInputState extends State { + final MobileScannerController _controller = MobileScannerController(); + + final boxFit = BoxFit.contain; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scanWindow = Rect.fromCenter( + center: + MediaQuery.of(context).size.center(Offset.zero).translate(0, -100), + width: 300, + height: 200, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MobileScanner( + controller: _controller, + scanWindow: scanWindow, + fit: boxFit, + onDetect: (barcode) { + if (barcode.raw != null) { + final String invoice = barcode.barcodes.first.rawValue!; + widget.controller.text = invoice; + // Once detected, immediately return the scanned invoice + } + }, + errorBuilder: (context, error, child) { + return Center( + child: Text( + 'Error: $error', + style: const TextStyle(color: Colors.red), + ), + ); + }, + ), + Positioned.fromRect( + rect: scanWindow, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.greenAccent, width: 2), + color: Colors.black.withOpacity(0.1), + ), + ), + ), + const SizedBox(height: 16), + Text( + "Please enter a Lightning Invoice:", + style: const TextStyle(color: AppTheme.cream1, fontSize: 16), + ), + const SizedBox(height: 8), + TextFormField( + key: const Key('invoiceTextField'), + controller: widget.controller, + style: const TextStyle(color: AppTheme.cream1), + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + labelText: "Lightning Invoice", + labelStyle: const TextStyle(color: AppTheme.grey2), + hintText: "Enter invoice here", + hintStyle: const TextStyle(color: AppTheme.grey2), + filled: true, + fillColor: AppTheme.dark1, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton( + key: const Key('cancelInvoiceButton'), + onPressed: widget.onCancel, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('CANCEL'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + key: const Key('submitInvoiceButton'), + onPressed: widget.onSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('SUBMIT'), + ), + ), + ], + ), + ], + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e7949dae..2a3ce279 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import awesome_notifications import flutter_secure_storage_darwin import local_auth_darwin +import mobile_scanner import path_provider_foundation import shared_preferences_foundation @@ -15,6 +16,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AwesomeNotificationsPlugin.register(with: registry.registrar(forPlugin: "AwesomeNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 0b34619f..1873fd49 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -737,6 +737,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: "91d28b825784e15572fdc39165c5733099ce0e69c6f6f0964ebdbf98a62130fd" + url: "https://pub.dev" + source: hosted + version: "6.0.6" mockito: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 66366896..204a25b0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,7 @@ dependencies: palette_generator: ^0.3.3+5 sembast: ^3.8.2 path_provider: ^2.1.5 + mobile_scanner: ^6.0.6 dev_dependencies: flutter_test: From af2c7151b42f180825df0dd519f8949ec5302573 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 15 Feb 2025 10:05:37 -0800 Subject: [PATCH 046/149] Separate our Lightning invoice components to widgets --- lib/data/models/nostr_event.dart | 2 - lib/data/repositories/session_storage.dart | 8 +- lib/features/home/widgets/order_filter.dart | 2 +- .../screens/add_lightning_invoice_screen.dart | 12 +- .../screens/pay_lightning_invoice_screen.dart | 110 +-------- .../trades/screens/trades_screen.dart | 2 +- ...dart => add_lightning_invoice_widget.dart} | 55 +---- lib/shared/widgets/custom_button.dart | 2 +- lib/shared/widgets/exchange_rate_widget.dart | 2 +- .../widgets/lightning_invoice_scanner.dart | 80 +++++++ .../widgets/pay_lightning_invoice_widget.dart | 98 ++++++++ pubspec.lock | 209 ++++++++---------- 12 files changed, 300 insertions(+), 282 deletions(-) rename lib/shared/widgets/{lightning_invoice_input.dart => add_lightning_invoice_widget.dart} (56%) create mode 100644 lib/shared/widgets/lightning_invoice_scanner.dart create mode 100644 lib/shared/widgets/pay_lightning_invoice_widget.dart diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index e8a1d6ae..4d2655ce 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -1,10 +1,8 @@ -import 'dart:convert'; import 'package:mostro_mobile/data/models/range_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/data/models/order.dart'; extension NostrEventExtensions on NostrEvent { // Getters para acceder fácilmente a los tags específicos diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index 44674ca5..b63e316e 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -1,3 +1,4 @@ +import 'package:dart_nostr/nostr/core/key_pairs.dart'; import 'package:sembast/sembast.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/session.dart'; @@ -94,16 +95,17 @@ class SessionStorage { /// Rebuilds a [Session] object from the DB record by re-deriving keys. Future _decodeSession(Map map) async { - final index = map['key_index'] as int; + //final index = map['key_index'] as int; // Re-derive trade key from index - final tradeKey = await _keyManager.deriveTradeKeyFromIndex(index); + // final tradeKey = await _keyManager.deriveTradeKeyFromIndex(index); // Re-get masterKey (potentially from secure storage/caching) final masterKey = await _keyManager.getMasterKey(); + final tradeKey = map['trade_key']; var clone = cloneMap(map); - clone['trade_key'] = tradeKey; + clone['trade_key'] = NostrKeyPairs(private: tradeKey); clone['master_key'] = masterKey; return Session.fromJson(clone); diff --git a/lib/features/home/widgets/order_filter.dart b/lib/features/home/widgets/order_filter.dart index 497eef87..abdc12ee 100644 --- a/lib/features/home/widgets/order_filter.dart +++ b/lib/features/home/widgets/order_filter.dart @@ -15,7 +15,7 @@ class OrderFilter extends StatelessWidget { borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: .1), blurRadius: 5, offset: Offset(0, 3), ), diff --git a/lib/features/take_order/screens/add_lightning_invoice_screen.dart b/lib/features/take_order/screens/add_lightning_invoice_screen.dart index 526d8051..8b9a3e98 100644 --- a/lib/features/take_order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/add_lightning_invoice_screen.dart @@ -7,7 +7,7 @@ import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; -import 'package:mostro_mobile/shared/widgets/lightning_invoice_input.dart'; +import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart'; class AddLightningInvoiceScreen extends ConsumerStatefulWidget { final String orderId; @@ -67,7 +67,7 @@ class _AddLightningInvoiceScreenState color: AppTheme.dark1, child: Padding( padding: const EdgeInsets.all(16), - child: LightningInvoiceInput( + child: AddLightningInvoiceWidget( controller: invoiceController, onSubmit: () async { final invoice = invoiceController.text.trim(); @@ -75,12 +75,13 @@ class _AddLightningInvoiceScreenState final orderRepo = ref.read(orderRepositoryProvider); try { // Here you would call your method to send or update the invoice. - + context.go('/'); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to update invoice: ${e.toString()}'), + content: Text( + 'Failed to update invoice: ${e.toString()}'), ), ); } @@ -95,7 +96,8 @@ class _AddLightningInvoiceScreenState } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to cancel order: ${e.toString()}'), + content: + Text('Failed to cancel order: ${e.toString()}'), ), ); } diff --git a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart b/lib/features/take_order/screens/pay_lightning_invoice_screen.dart index edbb38f2..9edee4c2 100644 --- a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/take_order/screens/pay_lightning_invoice_screen.dart @@ -1,13 +1,10 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/app/app_theme.dart'; import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; -import 'package:mostro_mobile/shared/widgets/custom_card.dart'; -import 'package:qr_flutter/qr_flutter.dart'; +import 'package:mostro_mobile/shared/widgets/pay_lightning_invoice_widget.dart'; class PayLightningInvoiceScreen extends ConsumerStatefulWidget { final String orderId; @@ -66,109 +63,8 @@ class _PayLightningInvoiceScreenState final lnInvoice = ''; // order.buyerInvoice!; - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: CustomCard( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'Pay this invoice to continue the exchange', - style: TextStyle(color: Colors.white, fontSize: 18), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(8.0), - color: Colors.white, - child: QrImageView( - data: lnInvoice, - version: QrVersions.auto, - size: 250.0, - backgroundColor: Colors.white, - errorStateBuilder: (cxt, err) { - return const Center( - child: Text( - 'Failed to generate QR code', - textAlign: TextAlign.center, - ), - ); - }, - ), - ), - const SizedBox(height: 20), - ElevatedButton.icon( - onPressed: () { - Clipboard.setData(ClipboardData(text: lnInvoice)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Invoice copied to clipboard!'), - duration: Duration(seconds: 2), - ), - ); - }, - icon: const Icon(Icons.copy), - label: const Text('Copy Invoice'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8CC541), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - ), - const SizedBox(height: 20), - // Open Wallet Button - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8CC541), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: - Text('Open wallet feature not implemented.'), - duration: Duration(seconds: 2), - ), - ); - }, - child: const Text('OPEN WALLET'), - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: () async { - final orderRepo = ref.read(orderRepositoryProvider); - try { - // We assume "cancel order" means deleting from DB - if (order.id != null) { - await orderRepo.deleteOrder(order.id!); - } - if (!mounted) return; - context.go('/'); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Failed to cancel order: ${e.toString()}'), - ), - ); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - child: const Text('CANCEL'), - ), - ], - ), - ), - ); + return PayLightningInvoiceWidget( + onSubmit: () async {}, onCancel: () async {}, lnInvoice: lnInvoice); } }, ), diff --git a/lib/features/trades/screens/trades_screen.dart b/lib/features/trades/screens/trades_screen.dart index b3501561..bda41ee4 100644 --- a/lib/features/trades/screens/trades_screen.dart +++ b/lib/features/trades/screens/trades_screen.dart @@ -75,7 +75,7 @@ class TradesScreen extends ConsumerWidget { } /// If your Session contains a full order snapshot, you could convert it. - /// Otherwise, update TradesList to accept a List. + /// Otherwise, update TradesList to accept a List. Widget _buildOrderList(List sessions) { if (sessions.isEmpty) { return const Center( diff --git a/lib/shared/widgets/lightning_invoice_input.dart b/lib/shared/widgets/add_lightning_invoice_widget.dart similarity index 56% rename from lib/shared/widgets/lightning_invoice_input.dart rename to lib/shared/widgets/add_lightning_invoice_widget.dart index 1b05af86..17962ba1 100644 --- a/lib/shared/widgets/lightning_invoice_input.dart +++ b/lib/shared/widgets/add_lightning_invoice_widget.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; -class LightningInvoiceInput extends StatefulWidget { +class AddLightningInvoiceWidget extends StatefulWidget { final TextEditingController controller; final VoidCallback onSubmit; final VoidCallback onCancel; - const LightningInvoiceInput({ + const AddLightningInvoiceWidget({ super.key, required this.controller, required this.onSubmit, @@ -15,62 +14,18 @@ class LightningInvoiceInput extends StatefulWidget { }); @override - State createState() => _LightningInvoiceInputState(); + State createState() => + _AddLightningInvoiceWidgetState(); } -class _LightningInvoiceInputState extends State { - final MobileScannerController _controller = MobileScannerController(); +class _AddLightningInvoiceWidgetState extends State { - final boxFit = BoxFit.contain; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } @override Widget build(BuildContext context) { - final scanWindow = Rect.fromCenter( - center: - MediaQuery.of(context).size.center(Offset.zero).translate(0, -100), - width: 300, - height: 200, - ); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MobileScanner( - controller: _controller, - scanWindow: scanWindow, - fit: boxFit, - onDetect: (barcode) { - if (barcode.raw != null) { - final String invoice = barcode.barcodes.first.rawValue!; - widget.controller.text = invoice; - // Once detected, immediately return the scanned invoice - } - }, - errorBuilder: (context, error, child) { - return Center( - child: Text( - 'Error: $error', - style: const TextStyle(color: Colors.red), - ), - ); - }, - ), - Positioned.fromRect( - rect: scanWindow, - child: Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.greenAccent, width: 2), - color: Colors.black.withOpacity(0.1), - ), - ), - ), - const SizedBox(height: 16), Text( "Please enter a Lightning Invoice:", style: const TextStyle(color: AppTheme.cream1, fontSize: 16), diff --git a/lib/shared/widgets/custom_button.dart b/lib/shared/widgets/custom_button.dart index c3c890d5..1baa16a7 100644 --- a/lib/shared/widgets/custom_button.dart +++ b/lib/shared/widgets/custom_button.dart @@ -20,7 +20,7 @@ class CustomButton extends StatelessWidget { style: ElevatedButton.styleFrom( backgroundColor: isEnabled ? AppTheme.mostroGreen - : AppTheme.mostroGreen.withOpacity(0.5), + : AppTheme.mostroGreen.withValues(alpha: .5), foregroundColor: Colors.black, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), diff --git a/lib/shared/widgets/exchange_rate_widget.dart b/lib/shared/widgets/exchange_rate_widget.dart index 2117d41b..2a8494d3 100644 --- a/lib/shared/widgets/exchange_rate_widget.dart +++ b/lib/shared/widgets/exchange_rate_widget.dart @@ -57,7 +57,7 @@ class ExchangeRateWidget extends ConsumerWidget { child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.3), + color: Colors.grey.withValues(alpha: .3), shape: BoxShape.circle, ), child: const Icon( diff --git a/lib/shared/widgets/lightning_invoice_scanner.dart b/lib/shared/widgets/lightning_invoice_scanner.dart new file mode 100644 index 00000000..2d2ad9b1 --- /dev/null +++ b/lib/shared/widgets/lightning_invoice_scanner.dart @@ -0,0 +1,80 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class LightningInvoiceScanner extends StatefulWidget { + const LightningInvoiceScanner({super.key}); + + @override + State createState() => + _LightningInvoiceScannerState(); +} + +class _LightningInvoiceScannerState extends State { + final MobileScannerController _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.unrestricted, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // If running on Linux, show an alternate widget + if (defaultTargetPlatform == TargetPlatform.linux) { + return Scaffold( + appBar: AppBar(title: const Text('Scan not supported')), + body: const Center( + child: Text('QR scanning is not supported on Linux.'), + ), + ); + } + + // Define a scan window + final scanWindow = Rect.fromCenter( + center: MediaQuery.of(context).size.center(Offset.zero).translate(0, -100), + width: 300, + height: 200, + ); + + return Scaffold( + appBar: AppBar( + title: const Text('Scan Lightning Invoice'), + ), + body: Stack( + children: [ + // Wrap in a container with explicit height to ensure proper sizing. + SizedBox( + width: double.infinity, + height: MediaQuery.of(context).size.height, + child: MobileScanner( + controller: _controller, + scanWindow: scanWindow, + errorBuilder: (context, error, child) { + return Center( + child: Text( + 'Error: $error', + style: const TextStyle(color: Colors.red), + ), + ); + }, + ), + ), + // Optional overlay to highlight the scan area + Positioned.fromRect( + rect: scanWindow, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.greenAccent, width: 2), + color: Colors.black.withValues(alpha: .1), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/pay_lightning_invoice_widget.dart b/lib/shared/widgets/pay_lightning_invoice_widget.dart new file mode 100644 index 00000000..74365c24 --- /dev/null +++ b/lib/shared/widgets/pay_lightning_invoice_widget.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +class PayLightningInvoiceWidget extends StatefulWidget { + final VoidCallback onSubmit; + final VoidCallback onCancel; + final String lnInvoice; + + const PayLightningInvoiceWidget({ + super.key, + required this.onSubmit, + required this.onCancel, + required this.lnInvoice, + }); + + @override + State createState() => + _PayLightningInvoiceWidgetState(); +} + +class _PayLightningInvoiceWidgetState extends State { + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Pay this invoice to continue the exchange', + style: TextStyle(color: Colors.white, fontSize: 18), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(8.0), + color: Colors.white, + child: QrImageView( + data: widget.lnInvoice, + version: QrVersions.auto, + size: 250.0, + backgroundColor: Colors.white, + errorStateBuilder: (cxt, err) { + return const Center( + child: Text( + 'Failed to generate QR code', + textAlign: TextAlign.center, + ), + ); + }, + ), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: widget.lnInvoice)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invoice copied to clipboard!'), + duration: Duration(seconds: 2), + ), + ); + }, + icon: const Icon(Icons.copy), + label: const Text('Copy Invoice'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF8CC541), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + const SizedBox(height: 20), + // Open Wallet Button + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF8CC541), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + onPressed: widget.onCancel, + child: const Text('OPEN WALLET'), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: widget.onSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + child: const Text('CANCEL'), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 1873fd49..ab2ecc3a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,23 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57 url: "https://pub.dev" source: hosted - version: "72.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.2" + version: "80.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e" url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "7.3.0" archive: dependency: transitive description: @@ -42,10 +37,10 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" awesome_notifications: dependency: "direct main" description: @@ -114,10 +109,10 @@ packages: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" bs58check: dependency: transitive description: @@ -130,50 +125,50 @@ packages: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.0.4" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" url: "https://pub.dev" source: hosted - version: "2.4.13" + version: "2.4.14" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" url: "https://pub.dev" source: hosted - version: "7.3.2" + version: "8.0.0" built_collection: dependency: transitive description: @@ -194,10 +189,10 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -218,10 +213,10 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" code_builder: dependency: transitive description: @@ -234,10 +229,10 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" convert: dependency: "direct main" description: @@ -290,10 +285,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" url: "https://pub.dev" source: hosted - version: "2.3.7" + version: "3.0.1" elliptic: dependency: "direct main" description: @@ -314,10 +309,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.2" ffi: dependency: transitive description: @@ -330,10 +325,10 @@ packages: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" fixnum: dependency: transitive description: @@ -496,10 +491,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: daf3ff5570f55396b2d2c9bf8136d7db3a8acf208ac0cef92a3ae2beb9a81550 + sha256: "04539267a740931c6d4479a10d466717ca5901c6fdfd3fcda09391bbb8ebd651" url: "https://pub.dev" source: hosted - version: "14.7.1" + version: "14.8.0" google_fonts: dependency: "direct main" description: @@ -560,10 +555,10 @@ packages: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" image: dependency: transitive description: @@ -597,10 +592,10 @@ packages: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2" json_annotation: dependency: transitive description: @@ -613,18 +608,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -637,10 +632,10 @@ packages: dependency: transitive description: name: lints - sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.1.1" local_auth: dependency: "direct main" description: @@ -697,22 +692,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: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -725,10 +712,10 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" mime: dependency: transitive description: @@ -749,10 +736,10 @@ packages: dependency: "direct dev" description: name: mockito - sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + sha256: f99d8d072e249f719a5531735d146d8cf04c580d93920b04de75bef6dfb2daf6 url: "https://pub.dev" source: hosted - version: "5.4.4" + version: "5.4.5" nip44: dependency: "direct main" description: @@ -790,10 +777,10 @@ packages: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_parsing: dependency: transitive description: @@ -854,18 +841,18 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.1.0" platform: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -902,10 +889,10 @@ packages: dependency: transitive description: name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.0.3" pub_semver: dependency: transitive description: @@ -918,10 +905,10 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" qr: dependency: transitive description: @@ -950,26 +937,26 @@ packages: dependency: "direct main" description: name: sembast - sha256: "6cf9acde19bd88dba9ea8090d3e50725cdc70fc7cf1c117c26e79c257884b04b" + sha256: f90377eb5ef66ac5a42d7afd72f4f900c436a2528d6b67b318fb2b9f5484bbe5 url: "https://pub.dev" source: hosted - version: "3.8.2" + version: "3.8.4" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: c59819dacc6669a1165d54d2735a9543f136f9b3cec94ca65cea6ab8dffc422e + sha256: "846849e3e9b68f3ef4b60c60cf4b3e02e9321bc7f4d8c4692cf87ffa82fc8a3a" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.5.2" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "986dc7b7d14f38064bfa85ace28df1f1a66d4fba32e4b1079d4ea537d9541b01" + sha256: ea86be7b7114f9e94fddfbb52649e59a03d6627ccd2387ebddcd6624719e9f16 url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.4.5" shared_preferences_foundation: dependency: transitive description: @@ -1014,10 +1001,10 @@ packages: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_packages_handler: dependency: transitive description: @@ -1046,15 +1033,15 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_gen: dependency: transitive description: name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "2.0.0" source_map_stack_trace: dependency: transitive description: @@ -1075,10 +1062,10 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" sprintf: dependency: transitive description: @@ -1091,10 +1078,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" state_notifier: dependency: transitive description: @@ -1107,10 +1094,10 @@ packages: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: @@ -1123,10 +1110,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" sync_http: dependency: transitive description: @@ -1139,42 +1126,42 @@ packages: dependency: transitive description: name: synchronized - sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" url: "https://pub.dev" source: hosted - version: "3.3.0+3" + version: "3.3.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test: dependency: "direct dev" description: name: test - sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" + sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" url: "https://pub.dev" source: hosted - version: "1.25.7" + version: "1.25.15" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.4" test_core: dependency: transitive description: name: test_core - sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" + sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.6.8" timeago: dependency: "direct main" description: @@ -1211,10 +1198,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "27d5fefe86fb9aace4a9f8375b56b3c292b64d8c04510df230f849850d912cb7" + sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" url: "https://pub.dev" source: hosted - version: "1.1.15" + version: "1.1.18" vector_graphics_codec: dependency: transitive description: @@ -1243,10 +1230,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.1" watcher: dependency: transitive description: @@ -1275,10 +1262,10 @@ packages: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.4" webkit_inspection_protocol: dependency: transitive description: @@ -1291,10 +1278,10 @@ packages: dependency: transitive description: name: win32 - sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e url: "https://pub.dev" source: hosted - version: "5.10.0" + version: "5.10.1" xdg_directories: dependency: transitive description: @@ -1320,5 +1307,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.5.4 <=3.9.9" + dart: ">=3.7.0 <=3.9.9" flutter: ">=3.24.0" From 39e2c271c2b555538a55b5ff002ae213e4639aa7 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 15 Feb 2025 16:41:51 -0800 Subject: [PATCH 047/149] Consolidated Drawer Screens --- integration_test/new_buy_order_test.dart | 2 +- integration_test/new_sell_order_test.dart | 2 +- lib/{app => core}/app.dart | 4 +- lib/{app => core}/app_routes.dart | 27 ++- lib/{app => core}/app_theme.dart | 0 lib/{app => core}/config.dart | 0 lib/data/models/enums/storage_keys.dart | 1 + lib/data/models/mostro_message.dart | 2 +- lib/data/repositories/mostro_repository.dart | 6 +- .../notifiers/add_order_notifier.dart | 21 -- .../add_order_notifier_provider.dart | 14 -- lib/features/auth/screens/welcome_screen.dart | 2 +- .../home/notifiers/home_notifier.dart | 8 +- lib/features/home/screens/home_screen.dart | 5 +- .../home/widgets/order_list_item.dart | 2 +- .../key_manager/key_management_screen.dart | 16 +- .../screens/messages_list_screen.dart | 17 +- lib/features/mostro/mostro_instance.dart | 3 + lib/features/mostro/mostro_screen.dart | 80 +------- .../notification_controller.dart | 2 +- .../notfiers/abstract_order_notifier.dart | 3 +- .../order/notfiers/order_notifier.dart | 36 +++- .../providers/order_notifier_provider.dart | 8 +- .../screens/add_lightning_invoice_screen.dart | 4 +- .../screens/add_order_screen.dart | 8 +- .../screens/error_screen.dart | 4 +- .../screens/order_confirmation_screen.dart | 4 +- .../screens/order_screen.dart | 14 +- .../screens/pay_lightning_invoice_screen.dart | 4 +- .../screens/take_order_screen.dart | 18 +- .../widgets/buy_form_widget.dart | 2 +- .../widgets/buyer_info.dart | 2 +- .../widgets/completion_message.dart | 4 +- .../widgets/fixed_switch_widget.dart | 0 .../widgets/order_app_bar.dart | 2 +- .../widgets/sell_form_widget.dart | 2 +- .../widgets/seller_info.dart | 2 +- .../rate/rate_counterpart_screen.dart | 2 +- lib/features/rate/star_rating.dart | 2 +- lib/features/relays/relays_notifier.dart | 2 +- lib/features/relays/relays_screen.dart | 6 +- lib/features/settings/about_screen.dart | 140 +++++++++++++ lib/features/settings/settings.dart | 22 +- .../settings/settings_controller.dart | 20 -- .../settings_controller_provider.dart | 8 - lib/features/settings/settings_notifier.dart | 51 +++++ lib/features/settings/settings_provider.dart | 10 + lib/features/settings/settings_screen.dart | 56 ++++++ .../notifiers/take_order_notifier.dart | 25 --- .../providers/order_notifier_providers.dart | 19 -- .../trades/notifiers/trades_notifier.dart | 47 ++++- .../trades/notifiers/trades_state.dart | 4 +- .../trades/providers/trades_provider.dart | 11 +- .../trades/screens/trades_detail_screen.dart | 121 +++++++++++ .../trades/screens/trades_screen.dart | 70 +++++-- lib/features/trades/widgets/trades_list.dart | 10 +- .../trades/widgets/trades_list_item.dart | 188 ++++++++++++++---- lib/main.dart | 2 +- lib/services/mostro_service.dart | 2 +- lib/services/nostr_service.dart | 12 +- .../widgets/add_lightning_invoice_widget.dart | 2 +- lib/shared/widgets/currency_dropdown.dart | 2 +- lib/shared/widgets/custom_button.dart | 2 +- lib/shared/widgets/custom_card.dart | 2 +- .../widgets/custom_elevated_button.dart | 2 +- lib/shared/widgets/mostro_app_drawer.dart | 20 +- .../home => shared}/widgets/order_filter.dart | 2 +- lib/shared/widgets/privacy_switch_widget.dart | 2 +- test/notifiers/add_order_notifier_test.dart | 18 +- test/notifiers/take_order_notifier_test.dart | 18 +- test/services/mostro_service_test.dart | 2 +- 71 files changed, 841 insertions(+), 392 deletions(-) rename lib/{app => core}/app.dart (95%) rename lib/{app => core}/app_routes.dart (76%) rename lib/{app => core}/app_theme.dart (100%) rename lib/{app => core}/config.dart (100%) delete mode 100644 lib/features/add_order/notifiers/add_order_notifier.dart delete mode 100644 lib/features/add_order/providers/add_order_notifier_provider.dart rename lib/features/{take_order => order}/screens/add_lightning_invoice_screen.dart (96%) rename lib/features/{add_order => order}/screens/add_order_screen.dart (97%) rename lib/features/{take_order => order}/screens/error_screen.dart (90%) rename lib/features/{add_order => order}/screens/order_confirmation_screen.dart (90%) rename lib/features/{take_order => order}/screens/order_screen.dart (86%) rename lib/features/{take_order => order}/screens/pay_lightning_invoice_screen.dart (94%) rename lib/features/{take_order => order}/screens/take_order_screen.dart (90%) rename lib/features/{add_order => order}/widgets/buy_form_widget.dart (98%) rename lib/features/{take_order => order}/widgets/buyer_info.dart (96%) rename lib/features/{take_order => order}/widgets/completion_message.dart (91%) rename lib/features/{add_order => order}/widgets/fixed_switch_widget.dart (100%) rename lib/features/{take_order => order}/widgets/order_app_bar.dart (94%) rename lib/features/{add_order => order}/widgets/sell_form_widget.dart (98%) rename lib/features/{take_order => order}/widgets/seller_info.dart (96%) create mode 100644 lib/features/settings/about_screen.dart delete mode 100644 lib/features/settings/settings_controller.dart delete mode 100644 lib/features/settings/settings_controller_provider.dart create mode 100644 lib/features/settings/settings_notifier.dart create mode 100644 lib/features/settings/settings_provider.dart create mode 100644 lib/features/settings/settings_screen.dart delete mode 100644 lib/features/take_order/notifiers/take_order_notifier.dart delete mode 100644 lib/features/take_order/providers/order_notifier_providers.dart create mode 100644 lib/features/trades/screens/trades_detail_screen.dart rename lib/{features/home => shared}/widgets/order_filter.dart (98%) diff --git a/integration_test/new_buy_order_test.dart b/integration_test/new_buy_order_test.dart index 11616ea7..24797921 100644 --- a/integration_test/new_buy_order_test.dart +++ b/integration_test/new_buy_order_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:mostro_mobile/features/add_order/screens/order_confirmation_screen.dart'; +import 'package:mostro_mobile/features/order/screens/order_confirmation_screen.dart'; import 'package:mostro_mobile/main.dart' as app; import 'package:flutter/material.dart'; diff --git a/integration_test/new_sell_order_test.dart b/integration_test/new_sell_order_test.dart index 9e8af10e..1557682f 100644 --- a/integration_test/new_sell_order_test.dart +++ b/integration_test/new_sell_order_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:mostro_mobile/features/add_order/screens/order_confirmation_screen.dart'; +import 'package:mostro_mobile/features/order/screens/order_confirmation_screen.dart'; import 'package:mostro_mobile/main.dart' as app; import 'package:flutter/material.dart'; diff --git a/lib/app/app.dart b/lib/core/app.dart similarity index 95% rename from lib/app/app.dart rename to lib/core/app.dart index 9d7e4385..17f21b42 100644 --- a/lib/app/app.dart +++ b/lib/core/app.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/app/app_routes.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_routes.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/features/auth/notifiers/auth_state.dart'; diff --git a/lib/app/app_routes.dart b/lib/core/app_routes.dart similarity index 76% rename from lib/app/app_routes.dart rename to lib/core/app_routes.dart index 6c388c7a..f9c298dd 100644 --- a/lib/app/app_routes.dart +++ b/lib/core/app_routes.dart @@ -1,18 +1,21 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; -import 'package:mostro_mobile/features/add_order/screens/add_order_screen.dart'; -import 'package:mostro_mobile/features/add_order/screens/order_confirmation_screen.dart'; +import 'package:mostro_mobile/features/order/screens/add_order_screen.dart'; +import 'package:mostro_mobile/features/order/screens/order_confirmation_screen.dart'; import 'package:mostro_mobile/features/auth/screens/welcome_screen.dart'; import 'package:mostro_mobile/features/messages/screens/messages_list_screen.dart'; import 'package:mostro_mobile/features/home/screens/home_screen.dart'; import 'package:mostro_mobile/features/key_manager/key_management_screen.dart'; import 'package:mostro_mobile/features/mostro/mostro_screen.dart'; +import 'package:mostro_mobile/features/settings/about_screen.dart'; +import 'package:mostro_mobile/features/settings/settings_screen.dart'; +import 'package:mostro_mobile/features/trades/screens/trades_detail_screen.dart'; import 'package:mostro_mobile/features/trades/screens/trades_screen.dart'; import 'package:mostro_mobile/features/relays/relays_screen.dart'; -import 'package:mostro_mobile/features/take_order/screens/add_lightning_invoice_screen.dart'; -import 'package:mostro_mobile/features/take_order/screens/pay_lightning_invoice_screen.dart'; -import 'package:mostro_mobile/features/take_order/screens/take_order_screen.dart'; +import 'package:mostro_mobile/features/order/screens/add_lightning_invoice_screen.dart'; +import 'package:mostro_mobile/features/order/screens/pay_lightning_invoice_screen.dart'; +import 'package:mostro_mobile/features/order/screens/take_order_screen.dart'; import 'package:mostro_mobile/features/auth/screens/register_screen.dart'; import 'package:mostro_mobile/shared/widgets/navigation_listener_widget.dart'; import 'package:mostro_mobile/shared/widgets/notification_listener_widget.dart'; @@ -41,6 +44,12 @@ final goRouter = GoRouter( path: '/order_book', builder: (context, state) => const TradesScreen(), ), + GoRoute( + path: '/trade_detail/:orderId', + builder: (context, state) => TradeDetailScreen( + orderId: state.pathParameters['orderId']!, + ), + ), GoRoute( path: '/chat_list', builder: (context, state) => const MessagesListScreen(), @@ -61,6 +70,14 @@ final goRouter = GoRouter( path: '/key_management', builder: (context, state) => const KeyManagementScreen(), ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsScreen(), + ), + GoRoute( + path: '/about', + builder: (context, state) => const AboutScreen(), + ), GoRoute( path: '/add_order', builder: (context, state) => AddOrderScreen(), diff --git a/lib/app/app_theme.dart b/lib/core/app_theme.dart similarity index 100% rename from lib/app/app_theme.dart rename to lib/core/app_theme.dart diff --git a/lib/app/config.dart b/lib/core/config.dart similarity index 100% rename from lib/app/config.dart rename to lib/core/config.dart diff --git a/lib/data/models/enums/storage_keys.dart b/lib/data/models/enums/storage_keys.dart index e25d77c4..6ed42b84 100644 --- a/lib/data/models/enums/storage_keys.dart +++ b/lib/data/models/enums/storage_keys.dart @@ -1,4 +1,5 @@ enum SharedPreferencesKeys { + appSettings('mostro_settings'), keyIndex('key_index'), fullPrivacy('full_privacy'); diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 08848c07..ac426e60 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -1,5 +1,5 @@ import 'dart:convert'; -import 'package:mostro_mobile/app/config.dart'; +import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/payload.dart'; diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index 6bf01ba2..6da3e241 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -80,17 +80,17 @@ class MostroRepository implements OrderRepository { Future saveMessages() async { for (var m in _messages.values.toList()) { - await _messageStorage.addOrder(m); + //await _messageStorage.addOrder(m); } } Future saveMessage(MostroMessage message) async { - await _messageStorage.addOrder(message); + //await _messageStorage.addOrder(message); } Future deleteMessage(String messageId) async { _messages.remove(messageId); - await _messageStorage.deleteOrder(messageId); + //await _messageStorage.deleteOrder(messageId); } Future loadMessages() async { diff --git a/lib/features/add_order/notifiers/add_order_notifier.dart b/lib/features/add_order/notifiers/add_order_notifier.dart deleted file mode 100644 index 4f72889d..00000000 --- a/lib/features/add_order/notifiers/add_order_notifier.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:async'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; -import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; - -class AddOrderNotifier extends AbstractOrderNotifier { - final String uuid; - - AddOrderNotifier(MostroRepository orderRepository, this.uuid, Ref ref) - : super(orderRepository, uuid, ref, Action.newOrder); - - Future submitOrder(Order order) async { - final message = - MostroMessage(action: Action.newOrder, id: null, payload: order); - final stream = await orderRepository.publishOrder(message); - await subscribe(stream); - } -} diff --git a/lib/features/add_order/providers/add_order_notifier_provider.dart b/lib/features/add_order/providers/add_order_notifier_provider.dart deleted file mode 100644 index ed7754c9..00000000 --- a/lib/features/add_order/providers/add_order_notifier_provider.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/enums/order_type.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/features/add_order/notifiers/add_order_notifier.dart'; -import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; - -// This provider tracks the currently selected OrderType tab -final orderTypeProvider = StateProvider((ref) => OrderType.sell); - -final addOrderNotifierProvider = - StateNotifierProvider.family((ref, uuid) { - final mostroService = ref.watch(mostroRepositoryProvider); - return AddOrderNotifier(mostroService, uuid, ref); -}); diff --git a/lib/features/auth/screens/welcome_screen.dart b/lib/features/auth/screens/welcome_screen.dart index 94dd66f2..a70f64bb 100644 --- a/lib/features/auth/screens/welcome_screen.dart +++ b/lib/features/auth/screens/welcome_screen.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/shared/widgets/custom_button.dart'; class WelcomeScreen extends StatelessWidget { diff --git a/lib/features/home/notifiers/home_notifier.dart b/lib/features/home/notifiers/home_notifier.dart index 3ddcddf8..a27f12a5 100644 --- a/lib/features/home/notifiers/home_notifier.dart +++ b/lib/features/home/notifiers/home_notifier.dart @@ -4,8 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; -import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'home_state.dart'; class HomeNotifier extends AsyncNotifier { @@ -52,8 +52,12 @@ class HomeNotifier extends AsyncNotifier { List _filterOrders(List orders, OrderType type) { final currentState = state.value; if (currentState == null) return []; - final mostroRepository = ref.watch(mostroRepositoryProvider); + + final sessionManager = ref.watch(sessionManagerProvider); + final orderIds = sessionManager.sessions.map((s) => s.orderId); + return orders + .where((order) => !orderIds.contains(order.orderId)) .where((order) => type == OrderType.buy ? order.orderType == OrderType.buy : order.orderType == OrderType.sell) diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index f9f440ff..3ecb38fc 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/features/home/notifiers/home_notifier.dart'; import 'package:mostro_mobile/features/home/providers/home_notifer_provider.dart'; import 'package:mostro_mobile/features/home/notifiers/home_state.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; -import 'package:mostro_mobile/features/home/widgets/order_filter.dart'; +import 'package:mostro_mobile/shared/widgets/order_filter.dart'; import 'package:mostro_mobile/features/home/widgets/order_list.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; @@ -39,6 +39,7 @@ class HomeScreen extends ConsumerWidget { child: Column( children: [ _buildTabs(ref, homeState, homeNotifier), + const SizedBox(height: 12.0), _buildFilterButton(context, homeState), const SizedBox(height: 6.0), Expanded( diff --git a/lib/features/home/widgets/order_list_item.dart b/lib/features/home/widgets/order_list_item.dart index 4b40c24e..70753667 100644 --- a/lib/features/home/widgets/order_list_item.dart +++ b/lib/features/home/widgets/order_list_item.dart @@ -2,7 +2,7 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/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/shared/widgets/custom_card.dart'; diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index f94a1998..350f6007 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -4,9 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; -import 'package:mostro_mobile/shared/widgets/privacy_switch_widget.dart'; class KeyManagementScreen extends ConsumerStatefulWidget { const KeyManagementScreen({super.key}); @@ -89,7 +88,6 @@ class _KeyManagementScreenState extends ConsumerState { @override Widget build(BuildContext context) { - bool isFullPrivacy = false; return Scaffold( appBar: AppBar( @@ -97,7 +95,7 @@ class _KeyManagementScreenState extends ConsumerState { elevation: 0, leading: IconButton( icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), - onPressed: () => context.go('/'), + onPressed: () => context.pop(), ), title: Text( 'KEY MANAGEMENT', @@ -188,16 +186,6 @@ class _KeyManagementScreenState extends ConsumerState { onPressed: _importKey, child: const Text('Import Key'), ), - const SizedBox(height: 16), - const Text( - 'Privacy', - style: TextStyle(color: AppTheme.cream1, fontSize: 18), - ), - const SizedBox(height: 8), - PrivacySwitch( - initialValue: isFullPrivacy, - onChanged: (newValue) {}, - ), ], ), ), diff --git a/lib/features/messages/screens/messages_list_screen.dart b/lib/features/messages/screens/messages_list_screen.dart index 861c8bf5..52b6470b 100644 --- a/lib/features/messages/screens/messages_list_screen.dart +++ b/lib/features/messages/screens/messages_list_screen.dart @@ -1,7 +1,7 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/messages/notifiers/messages_list_state.dart'; import 'package:mostro_mobile/features/messages/providers/messages_list_provider.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; @@ -32,12 +32,7 @@ class MessagesListScreen extends ConsumerWidget { padding: const EdgeInsets.all(16.0), child: Text( 'Messages', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - fontFamily: GoogleFonts.robotoCondensed().fontFamily, - ), + style: AppTheme.theme.textTheme.displayLarge, ), ), Expanded( @@ -56,9 +51,11 @@ class MessagesListScreen extends ConsumerWidget { return const Center(child: CircularProgressIndicator()); case MessagesListStatus.loaded: if (state.chats.isEmpty) { - return const Center( - child: Text('No chats available', - style: TextStyle(color: Colors.white))); + return Center( + child: Text( + 'No messages available', + style: AppTheme.theme.textTheme.displaySmall, + )); } return ListView.builder( itemCount: state.chats.length, diff --git a/lib/features/mostro/mostro_instance.dart b/lib/features/mostro/mostro_instance.dart index ac3b7263..e57ed7a8 100644 --- a/lib/features/mostro/mostro_instance.dart +++ b/lib/features/mostro/mostro_instance.dart @@ -1,6 +1,7 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; class MostroInstance { + final String pubKey; final String mostroVersion; final String commitHash; final int maxOrderAmount; @@ -14,6 +15,7 @@ class MostroInstance { final int invoiceExpirationWindow; MostroInstance( + this.pubKey, this.mostroVersion, this.commitHash, this.maxOrderAmount, @@ -29,6 +31,7 @@ class MostroInstance { factory MostroInstance.fromEvent(NostrEvent event) { return MostroInstance( + event.pubKey, event.mostroVersion, event.commitHash, event.maxOrderAmount, diff --git a/lib/features/mostro/mostro_screen.dart b/lib/features/mostro/mostro_screen.dart index 69842ad2..ead23c36 100644 --- a/lib/features/mostro/mostro_screen.dart +++ b/lib/features/mostro/mostro_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; @@ -45,21 +45,14 @@ class MostroScreen extends ConsumerWidget { ), child: Column( children: [ - const SizedBox(height: 24), - Center( - child: CircleAvatar( - radius: 50, - backgroundColor: Colors.grey, - foregroundImage: - AssetImage('assets/images/launcher-icon.png'), - ), - ), - Padding( padding: const EdgeInsets.all(16.0), - child: _buildInstanceDetails( - MostroInstance.fromEvent(nostrEvent)), + child: Text( + 'Mostro Messages', + style: AppTheme.theme.textTheme.displayLarge, + ), ), + const SizedBox(height: 24), // List of messages Expanded( child: ListView.builder( @@ -78,67 +71,6 @@ class MostroScreen extends ConsumerWidget { ); } - /// Builds the header displaying details from the MostroInstance. - Widget _buildInstanceDetails(MostroInstance instance) { - return CustomCard( - color: AppTheme.dark1, - padding: EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Version: ${instance.mostroVersion}', - style: GoogleFonts.robotoCondensed( - color: AppTheme.cream1, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - 'Commit Hash: ${instance.commitHash}', - ), - const SizedBox(height: 4), - Text( - 'Max Order Amount: ${instance.maxOrderAmount}', - ), - const SizedBox(height: 4), - Text( - 'Min Order Amount: ${instance.minOrderAmount}', - ), - const SizedBox(height: 4), - Text( - 'Expiration Hours: ${instance.expirationHours}', - ), - const SizedBox(height: 4), - Text( - 'Expiration Seconds: ${instance.expirationSeconds}', - ), - const SizedBox(height: 4), - Text( - 'Fee: ${instance.fee}', - ), - const SizedBox(height: 4), - Text( - 'Proof of Work: ${instance.pow}', - ), - const SizedBox(height: 4), - Text( - 'Hold Invoice Expiration Window: ${instance.holdInvoiceExpirationWindow}', - ), - const SizedBox(height: 4), - Text( - 'Hold Invoice CLTV Delta: ${instance.holdInvoiceCltvDelta}', - ), - const SizedBox(height: 4), - Text( - 'Invoice Expiration Window: ${instance.invoiceExpirationWindow}', - ), - const SizedBox(height: 4), - ], - )); - } - /// Builds a simple ListTile to display an individual MostroMessage. Widget _buildMessageTile(MostroMessage message) { return ListTile( diff --git a/lib/features/notifications/notification_controller.dart b/lib/features/notifications/notification_controller.dart index b0ac0e85..f5730f06 100644 --- a/lib/features/notifications/notification_controller.dart +++ b/lib/features/notifications/notification_controller.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:awesome_notifications/awesome_notifications.dart'; import 'package:flutter/material.dart'; -import 'package:mostro_mobile/app/app.dart'; +import 'package:mostro_mobile/core/app.dart'; import 'package:http/http.dart' as http; class NotificationController { diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 94ff6e41..9ef7b54a 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -22,8 +22,7 @@ class AbstractOrderNotifier extends StateNotifier { this.orderRepository, this.orderId, this.ref, - Action action, - ) : super(MostroMessage(action: action, id: orderId)); + ) : super(MostroMessage(action: Action.newOrder, id: orderId)); Future subscribe(Stream stream) async { try { diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 0f4e2fb2..936653c9 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -1,12 +1,13 @@ import 'dart:async'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; class OrderNotifier extends AbstractOrderNotifier { - OrderNotifier(super.orderRepository, super.orderId, super.ref, super.action) { - _reSubscribe(); - } + OrderNotifier(super.orderRepository, super.orderId, super.ref); - Future _reSubscribe() async { + Future reSubscribe() async { final existingMessage = await orderRepository.getOrderById(orderId); if (existingMessage == null) { logger.e('Order $orderId not found in repository; subscription aborted.'); @@ -15,4 +16,31 @@ class OrderNotifier extends AbstractOrderNotifier { final stream = orderRepository.resubscribeOrder(orderId); await subscribe(stream); } + + Future takeSellOrder( + String orderId, int? amount, String? lnAddress) async { + final stream = + await orderRepository.takeSellOrder(orderId, amount, lnAddress); + await subscribe(stream); + } + + Future takeBuyOrder(String orderId, int? amount) async { + final stream = await orderRepository.takeBuyOrder(orderId, amount); + await subscribe(stream); + } + + Future sendInvoice(String orderId, String invoice, int? amount) async { + await orderRepository.sendInvoice(orderId, invoice); + } + + Future cancelOrder() async { + await orderRepository.cancelOrder(orderId); + } + + Future submitOrder(Order order) async { + final message = + MostroMessage(action: Action.newOrder, id: null, payload: order); + final stream = await orderRepository.publishOrder(message); + await subscribe(stream); + } } diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index 3729cd5f..7054b9a4 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -1,18 +1,20 @@ import 'package:flutter_riverpod/flutter_riverpod.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/features/order/notfiers/order_notifier.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; final orderNotifierProvider = StateNotifierProvider.family( - (ref, orderId) { + (ref, orderId,) { final repo = ref.watch(mostroRepositoryProvider); return OrderNotifier( repo, orderId, ref, - Action.newOrder ); }, ); + +// This provider tracks the currently selected OrderType tab +final orderTypeProvider = StateProvider((ref) => OrderType.sell); diff --git a/lib/features/take_order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart similarity index 96% rename from lib/features/take_order/screens/add_lightning_invoice_screen.dart rename to lib/features/order/screens/add_lightning_invoice_screen.dart index 8b9a3e98..8d7b3684 100644 --- a/lib/features/take_order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -2,9 +2,9 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; -import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart'; diff --git a/lib/features/add_order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart similarity index 97% rename from lib/features/add_order/screens/add_order_screen.dart rename to lib/features/order/screens/add_order_screen.dart index 05a9e7d4..13ef00fb 100644 --- a/lib/features/add_order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -4,11 +4,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/features/add_order/providers/add_order_notifier_provider.dart'; -import 'package:mostro_mobile/features/add_order/widgets/fixed_switch_widget.dart'; +import 'package:mostro_mobile/features/order/widgets/fixed_switch_widget.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/shared/widgets/currency_dropdown.dart'; import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; @@ -448,7 +448,7 @@ class _AddOrderScreenState extends ConsumerState { // Generate a unique temporary ID for this new order final uuid = Uuid(); final tempOrderId = uuid.v4(); - final notifier = ref.read(addOrderNotifierProvider(tempOrderId).notifier); + final notifier = ref.read(orderNotifierProvider(tempOrderId).notifier); final fiatAmount = _maxFiatAmount != null ? 0 : _minFiatAmount; final minAmount = _maxFiatAmount != null ? _minFiatAmount : null; diff --git a/lib/features/take_order/screens/error_screen.dart b/lib/features/order/screens/error_screen.dart similarity index 90% rename from lib/features/take_order/screens/error_screen.dart rename to lib/features/order/screens/error_screen.dart index 7d0346d7..279b4a92 100644 --- a/lib/features/take_order/screens/error_screen.dart +++ b/lib/features/order/screens/error_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; class ErrorScreen extends StatelessWidget { final String errorMessage; diff --git a/lib/features/add_order/screens/order_confirmation_screen.dart b/lib/features/order/screens/order_confirmation_screen.dart similarity index 90% rename from lib/features/add_order/screens/order_confirmation_screen.dart rename to lib/features/order/screens/order_confirmation_screen.dart index 51ab9ff0..6b69ff6e 100644 --- a/lib/features/add_order/screens/order_confirmation_screen.dart +++ b/lib/features/order/screens/order_confirmation_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; diff --git a/lib/features/take_order/screens/order_screen.dart b/lib/features/order/screens/order_screen.dart similarity index 86% rename from lib/features/take_order/screens/order_screen.dart rename to lib/features/order/screens/order_screen.dart index 7d329e06..f09397cf 100644 --- a/lib/features/take_order/screens/order_screen.dart +++ b/lib/features/order/screens/order_screen.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; -import 'package:mostro_mobile/features/take_order/providers/order_notifier_providers.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; class TakeOrderScreen extends ConsumerStatefulWidget { final String orderId; @@ -30,9 +30,7 @@ class _TakeOrderScreenState extends ConsumerState { super.initState(); // Listen to notifier state changes: ref.listen( - widget.orderType == OrderType.buy - ? takeBuyOrderNotifierProvider(widget.orderId) - : takeSellOrderNotifierProvider(widget.orderId), + orderNotifierProvider(widget.orderId), (previous, next) { // If we’re waiting for a response and the state changes from our initial state, // then navigate accordingly. @@ -79,9 +77,7 @@ class _TakeOrderScreenState extends ConsumerState { _isLoading = true; }); // Depending on order type, call the appropriate notifier method. - final notifier = widget.orderType == OrderType.buy - ? ref.read(takeBuyOrderNotifierProvider(widget.orderId).notifier) - : ref.read(takeSellOrderNotifierProvider(widget.orderId).notifier); + final notifier = ref.read(orderNotifierProvider(widget.orderId).notifier); // Pass along any required parameters (e.g., fiat amount or LN address) as needed. // Here we assume null values for simplicity. if (widget.orderType == OrderType.buy) { diff --git a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart similarity index 94% rename from lib/features/take_order/screens/pay_lightning_invoice_screen.dart rename to lib/features/order/screens/pay_lightning_invoice_screen.dart index 9edee4c2..f0ba6f26 100644 --- a/lib/features/take_order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -1,8 +1,8 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/pay_lightning_invoice_widget.dart'; diff --git a/lib/features/take_order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart similarity index 90% rename from lib/features/take_order/screens/take_order_screen.dart rename to lib/features/order/screens/take_order_screen.dart index 261a1ff7..d93ad8a4 100644 --- a/lib/features/take_order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -2,17 +2,17 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/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/features/take_order/widgets/order_app_bar.dart'; -import 'package:mostro_mobile/features/take_order/widgets/seller_info.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/features/order/widgets/seller_info.dart'; import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; import 'package:mostro_mobile/shared/widgets/exchange_rate_widget.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; -import 'package:mostro_mobile/features/take_order/providers/order_notifier_providers.dart'; class TakeOrderScreen extends ConsumerWidget { final String orderId; @@ -142,10 +142,8 @@ class TakeOrderScreen extends ConsumerWidget { Widget _buildActionButtons( BuildContext context, WidgetRef ref, String orderId) { - - final orderDetailsNotifier = (orderType == OrderType.sell) - ? ref.read(takeSellOrderNotifierProvider(orderId).notifier) - : ref.read(takeBuyOrderNotifierProvider(orderId).notifier); + final orderDetailsNotifier = + ref.read(orderNotifierProvider(orderId).notifier); return Row( mainAxisAlignment: MainAxisAlignment.end, @@ -168,8 +166,8 @@ class TakeOrderScreen extends ConsumerWidget { await orderDetailsNotifier.takeBuyOrder(orderId, fiatAmount); } else { final lndAddress = _lndAddressController.text.trim(); - await orderDetailsNotifier.takeSellOrder(orderId, fiatAmount, - lndAddress.isEmpty ? null : lndAddress); + await orderDetailsNotifier.takeSellOrder( + orderId, fiatAmount, lndAddress.isEmpty ? null : lndAddress); } }, style: ElevatedButton.styleFrom( diff --git a/lib/features/add_order/widgets/buy_form_widget.dart b/lib/features/order/widgets/buy_form_widget.dart similarity index 98% rename from lib/features/add_order/widgets/buy_form_widget.dart rename to lib/features/order/widgets/buy_form_widget.dart index d57cca24..d12f9baa 100644 --- a/lib/features/add_order/widgets/buy_form_widget.dart +++ b/lib/features/order/widgets/buy_form_widget.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/shared/widgets/currency_dropdown.dart'; import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; diff --git a/lib/features/take_order/widgets/buyer_info.dart b/lib/features/order/widgets/buyer_info.dart similarity index 96% rename from lib/features/take_order/widgets/buyer_info.dart rename to lib/features/order/widgets/buyer_info.dart index f7af6bb5..716cdffb 100644 --- a/lib/features/take_order/widgets/buyer_info.dart +++ b/lib/features/order/widgets/buyer_info.dart @@ -1,6 +1,6 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; class BuyerInfo extends StatelessWidget { diff --git a/lib/features/take_order/widgets/completion_message.dart b/lib/features/order/widgets/completion_message.dart similarity index 91% rename from lib/features/take_order/widgets/completion_message.dart rename to lib/features/order/widgets/completion_message.dart index 11a235bd..ce35b2e9 100644 --- a/lib/features/take_order/widgets/completion_message.dart +++ b/lib/features/order/widgets/completion_message.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/features/take_order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; class CompletionMessage extends StatelessWidget { final String message; diff --git a/lib/features/add_order/widgets/fixed_switch_widget.dart b/lib/features/order/widgets/fixed_switch_widget.dart similarity index 100% rename from lib/features/add_order/widgets/fixed_switch_widget.dart rename to lib/features/order/widgets/fixed_switch_widget.dart diff --git a/lib/features/take_order/widgets/order_app_bar.dart b/lib/features/order/widgets/order_app_bar.dart similarity index 94% rename from lib/features/take_order/widgets/order_app_bar.dart rename to lib/features/order/widgets/order_app_bar.dart index 0ff7f318..457c00f1 100644 --- a/lib/features/take_order/widgets/order_app_bar.dart +++ b/lib/features/order/widgets/order_app_bar.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class OrderAppBar extends StatelessWidget implements PreferredSizeWidget { final String title; diff --git a/lib/features/add_order/widgets/sell_form_widget.dart b/lib/features/order/widgets/sell_form_widget.dart similarity index 98% rename from lib/features/add_order/widgets/sell_form_widget.dart rename to lib/features/order/widgets/sell_form_widget.dart index 1bbbecea..6465b1a2 100644 --- a/lib/features/add_order/widgets/sell_form_widget.dart +++ b/lib/features/order/widgets/sell_form_widget.dart @@ -2,7 +2,7 @@ import 'package:bitcoin_icons/bitcoin_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/shared/widgets/currency_dropdown.dart'; import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; diff --git a/lib/features/take_order/widgets/seller_info.dart b/lib/features/order/widgets/seller_info.dart similarity index 96% rename from lib/features/take_order/widgets/seller_info.dart rename to lib/features/order/widgets/seller_info.dart index a37f5a20..492c5620 100644 --- a/lib/features/take_order/widgets/seller_info.dart +++ b/lib/features/order/widgets/seller_info.dart @@ -1,6 +1,6 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; class SellerInfo extends StatelessWidget { diff --git a/lib/features/rate/rate_counterpart_screen.dart b/lib/features/rate/rate_counterpart_screen.dart index 5fd36ee7..8a400a0b 100644 --- a/lib/features/rate/rate_counterpart_screen.dart +++ b/lib/features/rate/rate_counterpart_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:logger/logger.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'star_rating.dart'; class RateCounterpartScreen extends StatefulWidget { diff --git a/lib/features/rate/star_rating.dart b/lib/features/rate/star_rating.dart index 1f8ca3e9..da623351 100644 --- a/lib/features/rate/star_rating.dart +++ b/lib/features/rate/star_rating.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class StarRating extends StatefulWidget { /// The initial rating (between 0 and 5). diff --git a/lib/features/relays/relays_notifier.dart b/lib/features/relays/relays_notifier.dart index ff6b30c9..70d18e25 100644 --- a/lib/features/relays/relays_notifier.dart +++ b/lib/features/relays/relays_notifier.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:mostro_mobile/app/config.dart'; // Assumes Config.initialRelays exists. +import 'package:mostro_mobile/core/config.dart'; // Assumes Config.initialRelays exists. import 'relay.dart'; class RelaysNotifier extends StateNotifier> { diff --git a/lib/features/relays/relays_screen.dart b/lib/features/relays/relays_screen.dart index a830eebd..483d4a5c 100644 --- a/lib/features/relays/relays_screen.dart +++ b/lib/features/relays/relays_screen.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'relays_provider.dart'; import 'relay.dart'; @@ -19,13 +18,12 @@ class RelaysScreen extends ConsumerWidget { elevation: 0, leading: IconButton( icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), - onPressed: () => context.go('/'), + onPressed: () => context.pop(), ), title: Text( 'RELAYS', style: TextStyle( color: AppTheme.cream1, - fontFamily: GoogleFonts.robotoCondensed().fontFamily, ), ), ), diff --git a/lib/features/settings/about_screen.dart b/lib/features/settings/about_screen.dart new file mode 100644 index 00000000..2b10ef15 --- /dev/null +++ b/lib/features/settings/about_screen.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:intl/intl.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/widgets/custom_card.dart'; + +class AboutScreen extends ConsumerWidget { + static final textTheme = AppTheme.theme.textTheme; + + const AboutScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nostrEvent = ref.watch(orderRepositoryProvider).mostroInstance; + + return nostrEvent == null + ? Scaffold( + backgroundColor: AppTheme.dark1, + body: const Center(child: CircularProgressIndicator()), + ) + : Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: + const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), + onPressed: () => context.pop(), + ), + title: Text( + 'ABOUT', + style: TextStyle( + color: AppTheme.cream1, + ), + ), + ), + backgroundColor: AppTheme.dark1, + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.dark2, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + const SizedBox(height: 24), + Center( + child: CircleAvatar( + radius: 50, + backgroundColor: Colors.grey, + foregroundImage: + AssetImage('assets/images/launcher-icon.png'), + ), + ), + const SizedBox(height: 16), + Text( + 'Mostro', + style: textTheme.displayLarge, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: _buildInstanceDetails( + MostroInstance.fromEvent(nostrEvent)), + ), + ], + ), + ), + ), + ); + } + + /// Builds the header displaying details from the MostroInstance. + Widget _buildInstanceDetails(MostroInstance instance) { + final formatter = NumberFormat.decimalPattern(Intl.getCurrentLocale()); + + return CustomCard( + color: AppTheme.dark1, + padding: EdgeInsets.all(24), + child: Column( + spacing: 3.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pubkey: ${instance.pubKey}', + ), + const SizedBox(height: 4), + Text( + 'Version: ${instance.mostroVersion}', + ), + const SizedBox(height: 4), + Text( + 'Commit Hash: ${instance.commitHash}', + ), + const SizedBox(height: 4), + Text( + 'Max Order Amount: ${formatter.format(instance.maxOrderAmount)}', + ), + const SizedBox(height: 4), + Text( + 'Min Order Amount: ${formatter.format(instance.minOrderAmount)}', + ), + const SizedBox(height: 4), + Text( + 'Expiration Hours: ${instance.expirationHours}', + ), + const SizedBox(height: 4), + Text( + 'Expiration Seconds: ${instance.expirationSeconds}', + ), + const SizedBox(height: 4), + Text( + 'Fee: ${instance.fee}', + ), + const SizedBox(height: 4), + Text( + 'Proof of Work: ${instance.pow}', + ), + const SizedBox(height: 4), + Text( + 'Hold Invoice Expiration Window: ${instance.holdInvoiceExpirationWindow}', + ), + const SizedBox(height: 4), + Text( + 'Hold Invoice CLTV Delta: ${instance.holdInvoiceCltvDelta}', + ), + const SizedBox(height: 4), + Text( + 'Invoice Expiration Window: ${instance.invoiceExpirationWindow}', + ), + const SizedBox(height: 4), + ], + )); + } +} diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart index f7c5ba67..77199070 100644 --- a/lib/features/settings/settings.dart +++ b/lib/features/settings/settings.dart @@ -1,11 +1,25 @@ class Settings { final bool fullPrivacyMode; + final List relays; - Settings({required this.fullPrivacyMode}); + Settings({required this.relays, required this.fullPrivacyMode}); - factory Settings.intial() => Settings(fullPrivacyMode: false); + Settings copyWith({List? relays, bool? privacyModeSetting}) { + return Settings( + relays: relays ?? this.relays, + fullPrivacyMode: privacyModeSetting ?? fullPrivacyMode, + ); + } + + Map toJson() => { + 'relays': relays, + 'fullPrivacyMode': fullPrivacyMode, + }; - Settings copyWith({required bool fullPrivacyMode}) { - return Settings(fullPrivacyMode: fullPrivacyMode); + factory Settings.fromJson(Map json) { + return Settings( + relays: (json['relays'] as List?)?.cast() ?? [], + fullPrivacyMode: json[' fullPrivacyMode'] as bool, + ); } } diff --git a/lib/features/settings/settings_controller.dart b/lib/features/settings/settings_controller.dart deleted file mode 100644 index 18ab39cc..00000000 --- a/lib/features/settings/settings_controller.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/settings/settings.dart'; -import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; -import 'package:mostro_mobile/shared/providers/storage_providers.dart'; - -class SettingsController extends StateNotifier { - final Ref ref; - - SettingsController(this.ref) : super(Settings.intial()); - - Future loadSettings() async { - final prefs = ref.read(sharedPreferencesProvider); - - final fullPrivacyMode = - await prefs.getBool(SharedPreferencesKeys.fullPrivacy.toString()) ?? - true; - - state = state.copyWith(fullPrivacyMode: fullPrivacyMode); - } -} diff --git a/lib/features/settings/settings_controller_provider.dart b/lib/features/settings/settings_controller_provider.dart deleted file mode 100644 index e9c9a473..00000000 --- a/lib/features/settings/settings_controller_provider.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/settings/settings.dart'; -import 'package:mostro_mobile/features/settings/settings_controller.dart'; - -final settingsControllerProvider = - StateNotifierProvider((ref) { - return SettingsController(ref); -}); diff --git a/lib/features/settings/settings_notifier.dart b/lib/features/settings/settings_notifier.dart new file mode 100644 index 00000000..d48170a3 --- /dev/null +++ b/lib/features/settings/settings_notifier.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SettingsNotifier extends StateNotifier { + final SharedPreferencesAsync _prefs; + static final String _storageKey = SharedPreferencesKeys.appSettings.value; + + SettingsNotifier(this._prefs) : super(_defaultSettings()) { + _init(); + } + + static Settings _defaultSettings() { + return Settings( + relays: Config.nostrRelays, + fullPrivacyMode: false, + ); + } + + Future _init() async { + final settingsJson = await _prefs.getString(_storageKey); + if (settingsJson != null) { + try { + final loaded = Settings.fromJson(jsonDecode(settingsJson)); + state = loaded; + } catch (_) { + state = _defaultSettings(); + } + } else { + state = _defaultSettings(); + } + } + + Future updateRelays(List newRelays) async { + state = state.copyWith(relays: newRelays); + await _saveToPrefs(); + } + + Future updatePrivacyModeSetting(bool newValue) async { + state = state.copyWith(privacyModeSetting: newValue); + await _saveToPrefs(); + } + + Future _saveToPrefs() async { + final jsonString = jsonEncode(state.toJson()); + await _prefs.setString(_storageKey, jsonString); + } +} diff --git a/lib/features/settings/settings_provider.dart b/lib/features/settings/settings_provider.dart new file mode 100644 index 00000000..6971c3d2 --- /dev/null +++ b/lib/features/settings/settings_provider.dart @@ -0,0 +1,10 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_notifier.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; + +final settingsProvider = + StateNotifierProvider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return SettingsNotifier(prefs); +}); diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart new file mode 100644 index 00000000..7b3aeddb --- /dev/null +++ b/lib/features/settings/settings_screen.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/shared/widgets/privacy_switch_widget.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), + onPressed: () => context.pop(), + ), + title: Text( + 'APP SETTINGS', + style: TextStyle( + color: AppTheme.cream1, + ), + ), + ), + backgroundColor: AppTheme.dark1, + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + const SizedBox(height: 16), + const Text( + 'Privacy', + style: TextStyle(color: AppTheme.cream1, fontSize: 18), + ), + const SizedBox(height: 8), + PrivacySwitch( + initialValue: settings.fullPrivacyMode, + onChanged: (newValue) { + ref + .watch(settingsProvider.notifier) + .updatePrivacyModeSetting(newValue); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} diff --git a/lib/features/take_order/notifiers/take_order_notifier.dart b/lib/features/take_order/notifiers/take_order_notifier.dart deleted file mode 100644 index 5345f98d..00000000 --- a/lib/features/take_order/notifiers/take_order_notifier.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; - -class TakeOrderNotifier extends AbstractOrderNotifier { - TakeOrderNotifier( - super.orderRepository, super.orderId, super.ref, super.action); - - Future takeSellOrder(String orderId, int? amount, String? lnAddress) async { - final stream = - await orderRepository.takeSellOrder(orderId, amount, lnAddress); - await subscribe(stream); - } - - Future takeBuyOrder(String orderId, int? amount) async { - final stream = await orderRepository.takeBuyOrder(orderId, amount); - await subscribe(stream); - } - - Future sendInvoice(String orderId, String invoice, int? amount) async { - await orderRepository.sendInvoice(orderId, invoice); - } - - Future cancelOrder() async { - await orderRepository.cancelOrder(orderId); - } -} diff --git a/lib/features/take_order/providers/order_notifier_providers.dart b/lib/features/take_order/providers/order_notifier_providers.dart deleted file mode 100644 index 51c89040..00000000 --- a/lib/features/take_order/providers/order_notifier_providers.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/features/take_order/notifiers/take_order_notifier.dart'; -import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; - -final takeSellOrderNotifierProvider = - StateNotifierProvider.family( - (ref, orderId) { - final repository = ref.watch(mostroRepositoryProvider); - return TakeOrderNotifier(repository, orderId, ref, Action.takeSell); -}); - -final takeBuyOrderNotifierProvider = - StateNotifierProvider.family( - (ref, orderId) { - final repository = ref.watch(mostroRepositoryProvider); - return TakeOrderNotifier(repository, orderId, ref, Action.takeBuy); -}); diff --git a/lib/features/trades/notifiers/trades_notifier.dart b/lib/features/trades/notifiers/trades_notifier.dart index fa4091eb..b116c03f 100644 --- a/lib/features/trades/notifiers/trades_notifier.dart +++ b/lib/features/trades/notifiers/trades_notifier.dart @@ -1,13 +1,58 @@ import 'dart:async'; - +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; class TradesNotifier extends AsyncNotifier { + OpenOrdersRepository? _repository; + StreamSubscription>? _subscription; + + @override FutureOr build() async { state = const AsyncLoading(); + _repository = ref.watch(orderRepositoryProvider); + _repository!.subscribeToOrders(); + + _subscription = _repository!.eventsStream.listen((orders) { + final filteredOrders = _filterOrders(orders); + state = AsyncData( + TradesState( + filteredOrders, + ), + ); + }, onError: (error) { + state = AsyncError(error, StackTrace.current); + }); + + ref.onDispose(() { + _subscription?.cancel(); + }); + return TradesState([]); } + + /// Refreshes the data by re-initializing the notifier. + Future refresh() async { + _subscription?.cancel(); + await build(); + } + + List _filterOrders(List orders) { + final currentState = state.value; + if (currentState == null) return []; + + final sessionManager = ref.watch(sessionManagerProvider); + final orderIds = sessionManager.sessions.map((s) => s.orderId); + + return orders + .where((order) => orderIds.contains(order.orderId)) + .toList(); + } + } diff --git a/lib/features/trades/notifiers/trades_state.dart b/lib/features/trades/notifiers/trades_state.dart index e7988d88..9fbf4321 100644 --- a/lib/features/trades/notifiers/trades_state.dart +++ b/lib/features/trades/notifiers/trades_state.dart @@ -1,7 +1,7 @@ -import 'package:mostro_mobile/data/models/order.dart'; +import 'package:dart_nostr/dart_nostr.dart'; class TradesState { - final List orders; + final List orders; TradesState(this.orders); } diff --git a/lib/features/trades/providers/trades_provider.dart b/lib/features/trades/providers/trades_provider.dart index ee4b4b81..62168d1c 100644 --- a/lib/features/trades/providers/trades_provider.dart +++ b/lib/features/trades/providers/trades_provider.dart @@ -1,8 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; +import 'package:mostro_mobile/features/trades/notifiers/trades_notifier.dart'; +import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; -final tradesProvider = FutureProvider>((ref) async { - final sessionManager = ref.read(sessionManagerProvider); - return sessionManager.sessions; -}); +final tradesProvider = AsyncNotifierProvider( + TradesNotifier.new, +); diff --git a/lib/features/trades/screens/trades_detail_screen.dart b/lib/features/trades/screens/trades_detail_screen.dart new file mode 100644 index 00000000..ef4e9816 --- /dev/null +++ b/lib/features/trades/screens/trades_detail_screen.dart @@ -0,0 +1,121 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/features/order/widgets/seller_info.dart'; +import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; +import 'package:mostro_mobile/shared/widgets/exchange_rate_widget.dart'; +import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/widgets/custom_card.dart'; + +class TradeDetailScreen extends ConsumerWidget { + final String orderId; + final TextEditingController _fiatAmountController = TextEditingController(); + + TradeDetailScreen({super.key, required this.orderId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final orderAsyncValue = ref.watch(eventProvider(orderId)); + + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), + onPressed: () => context.go('/order_book'), + ), + title: Text( + 'TRADE DETAIL', + style: AppTheme.theme.textTheme.displayLarge, + ), + ), + body: orderAsyncValue.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center(child: Text('Error: $error')), + data: (order) { + if (order == null) { + return Center(child: Text('Order $orderId not found')); + } + // Build the main UI with the order + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + CustomCard( + padding: const EdgeInsets.all(16), + child: SellerInfo(order: order), + ), + const SizedBox(height: 16), + _buildSellerAmount(ref, order), + const SizedBox(height: 16), + ExchangeRateWidget(currency: order.currency!), + const SizedBox(height: 16), + _buildBuyerAmount(int.tryParse(order.amount!)), + ], + ), + ); + }, + ), + ); + } + + Widget _buildSellerAmount(WidgetRef ref, NostrEvent order) { + final exchangeRateAsyncValue = + ref.watch(exchangeRateProvider(order.currency!)); + return exchangeRateAsyncValue.when( + loading: () => const CircularProgressIndicator(), + error: (error, _) => Text('Exchange rate error: $error'), + data: (exchangeRate) { + // Example usage: exchangeRate might be a double or something + return CustomCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 _buildBuyerAmount(int? amount) { + return Column(children: [ + CustomCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CurrencyTextField(controller: _fiatAmountController, label: 'Fiat'), + const SizedBox(height: 8), + ], + ), + ), + const SizedBox(height: 16), + ]); + } + +} diff --git a/lib/features/trades/screens/trades_screen.dart b/lib/features/trades/screens/trades_screen.dart index bda41ee4..a5f12b07 100644 --- a/lib/features/trades/screens/trades_screen.dart +++ b/lib/features/trades/screens/trades_screen.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/shared/widgets/order_filter.dart'; +import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; import 'package:mostro_mobile/features/trades/providers/trades_provider.dart'; import 'package:mostro_mobile/features/trades/widgets/trades_list.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; -import 'package:mostro_mobile/data/models/session.dart'; class TradesScreen extends ConsumerWidget { const TradesScreen({super.key}); @@ -17,7 +18,7 @@ class TradesScreen extends ConsumerWidget { final tradesAsync = ref.watch(tradesProvider); return tradesAsync.when( - data: (sessions) { + data: (state) { return Scaffold( backgroundColor: AppTheme.dark1, appBar: const MostroAppBar(), @@ -25,7 +26,7 @@ class TradesScreen extends ConsumerWidget { body: RefreshIndicator( onRefresh: () async { // Force a refresh of sessions - ref.refresh(tradesProvider); + //ref.refresh(tradesProvider); }, child: Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), @@ -39,17 +40,13 @@ class TradesScreen extends ConsumerWidget { padding: const EdgeInsets.all(16.0), child: Text( 'My Trades', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - fontFamily: - GoogleFonts.robotoCondensed().fontFamily, - ), + style: AppTheme.theme.textTheme.displayLarge, ), ), + _buildFilterButton(context, state), + const SizedBox(height: 6.0), Expanded( - child: _buildOrderList(sessions), + child: _buildOrderList(state), ), const BottomNavBar(), ], @@ -74,10 +71,47 @@ class TradesScreen extends ConsumerWidget { ); } - /// If your Session contains a full order snapshot, you could convert it. - /// Otherwise, update TradesList to accept a List. - Widget _buildOrderList(List sessions) { - if (sessions.isEmpty) { + Widget _buildFilterButton(BuildContext context, TradesState homeState) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: () { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return const Padding( + padding: 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)), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.white), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + const SizedBox(width: 8), + Text( + "${homeState.orders.length} trades", + style: const TextStyle(color: Colors.white), + ), + ], + ), + ); + } + + + Widget _buildOrderList(TradesState state) { + if (state.orders.isEmpty) { return const Center( child: Text( 'No trades available for this type', @@ -86,6 +120,6 @@ class TradesScreen extends ConsumerWidget { ); } - return TradesList(sessions: sessions); + return TradesList(state: state); } } diff --git a/lib/features/trades/widgets/trades_list.dart b/lib/features/trades/widgets/trades_list.dart index 67058f6b..f0d89342 100644 --- a/lib/features/trades/widgets/trades_list.dart +++ b/lib/features/trades/widgets/trades_list.dart @@ -1,18 +1,18 @@ import 'package:flutter/material.dart'; -import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; import 'package:mostro_mobile/features/trades/widgets/trades_list_item.dart'; class TradesList extends StatelessWidget { - final List sessions; + final TradesState state; - const TradesList({super.key, required this.sessions}); + const TradesList({super.key, required this.state}); @override Widget build(BuildContext context) { return ListView.builder( - itemCount: sessions.length, + itemCount: state.orders.length, itemBuilder: (context, index) { - return TradesListItem(session: sessions[index]); + return TradesListItem(trade: state.orders[index]); }, ); } diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index f71577b6..904bed2c 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -1,19 +1,23 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; -import 'package:mostro_mobile/data/models/session.dart'; +import 'package:intl/intl.dart'; +import 'package:mostro_mobile/core/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/shared/widgets/custom_card.dart'; class TradesListItem extends StatelessWidget { - final Session session; + final NostrEvent trade; - const TradesListItem({super.key, required this.session}); + const TradesListItem({super.key, required this.trade}); @override Widget build(BuildContext context) { return GestureDetector( onTap: () { - // TODO: Navigate to a detail screen or hydrate the session with full order data. + context.go('/trade_detail/${trade.orderId}'); }, child: CustomCard( color: AppTheme.dark1, @@ -36,16 +40,14 @@ class TradesListItem extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Display the order ID (or a placeholder if not yet assigned) Text( - session.orderId ?? 'No Order', + '${toBeginningOfSentenceCase(trade.status)}', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: AppTheme.cream1, ), ), - // Display a formatted start time (for example, hour and minute) Text( - 'Time: ${session.startTime.hour.toString().padLeft(2, '0')}:${session.startTime.minute.toString().padLeft(2, '0')}', + 'Time: ${trade.expiration}', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: AppTheme.cream1, ), @@ -57,48 +59,146 @@ class TradesListItem extends StatelessWidget { Widget _buildSessionDetails(BuildContext context) { return Row( children: [ - // Display trade key index or other session summary info - Expanded( - flex: 3, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Trade Key: ${session.keyIndex}', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.cream1, - ), - ), - // You could add more session details here as needed. - ], - ), - ), - // Display a placeholder for status or payment method info + _getOrderOffering(context, trade), + const SizedBox(width: 16), Expanded( flex: 4, - child: Row( - children: [ - HeroIcon( - HeroIcons.banknotes, - style: HeroIconStyle.outline, - color: AppTheme.cream1, - size: 16, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - 'Status: pending', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.grey2, + child: _buildPaymentMethod(context), + ), + ], + ); + } + + Widget _getOrderOffering(BuildContext context, NostrEvent trade) { + String offering = trade.orderType == OrderType.buy ? 'Selling' : 'Buying'; + String amountText = (trade.amount != null && trade.amount != '0') + ? ' ${trade.amount!}' + : ''; + + return Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + children: [ + _buildStyledTextSpan( + context, + offering, + amountText, + isValue: true, + isBold: true, + ), + TextSpan( + text: 'sats', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + fontWeight: FontWeight.normal, ), - overflow: TextOverflow.ellipsis, - softWrap: true, ), - ), - ], + ], + ), + ), + const SizedBox(height: 8.0), + RichText( + text: TextSpan( + children: [ + _buildStyledTextSpan( + context, + 'for ', + '${trade.fiatAmount}', + isValue: true, + isBold: true, + ), + TextSpan( + text: '${trade.currency} ', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + fontSize: 16.0, + ), + ), + TextSpan( + text: '(${trade.premium}%)', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + fontSize: 16.0, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPaymentMethod(BuildContext context) { + String method = trade.paymentMethods.isNotEmpty + ? trade.paymentMethods[0] + : 'No payment method'; + + return Row( + children: [ + HeroIcon( + _getPaymentMethodIcon(method), + style: HeroIconStyle.outline, + color: AppTheme.cream1, + size: 16, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + method, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.grey2, + ), + overflow: TextOverflow.ellipsis, + softWrap: true, ), ), ], ); } + + HeroIcons _getPaymentMethodIcon(String method) { + switch (method.toLowerCase()) { + case 'wire transfer': + case 'transferencia bancaria': + return HeroIcons.buildingLibrary; + case 'revolut': + return HeroIcons.creditCard; + default: + return HeroIcons.banknotes; + } + } + + TextSpan _buildStyledTextSpan( + BuildContext context, + String label, + String value, { + bool isValue = false, + bool isBold = false, + }) { + return TextSpan( + text: label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.cream1, + fontWeight: FontWeight.normal, + fontSize: isValue ? 16.0 : 24.0, + ), + children: isValue + ? [ + TextSpan( + text: '$value ', + style: Theme.of(context).textTheme.displayLarge?.copyWith( + fontWeight: isBold ? FontWeight.bold : FontWeight.normal, + fontSize: 24.0, + color: AppTheme.cream1, + ), + ), + ] + : [], + ); + } } diff --git a/lib/main.dart b/lib/main.dart index a95ba981..0c791b13 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:mostro_mobile/app/app.dart'; +import 'package:mostro_mobile/core/app.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/features/notifications/notification_controller.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 5be8366e..90d177de 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -3,7 +3,7 @@ import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:logger/logger.dart'; -import 'package:mostro_mobile/app/config.dart'; +import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 209f124a..625ece71 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -1,6 +1,7 @@ import 'package:dart_nostr/dart_nostr.dart'; +import 'package:dart_nostr/nostr/model/relay_informations.dart'; import 'package:logger/logger.dart'; -import 'package:mostro_mobile/app/config.dart'; +import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class NostrService { @@ -26,6 +27,9 @@ class NostrService { onRelayConnectionError: (relay, error, channel) { _logger.w('Failed to connect to relay $relay: $error'); }, + retryOnClose: true, + retryOnError: true, + shouldReconnectToRelayOnNotice: true, ); _isInitialized = true; _logger.i('Nostr initialized successfully'); @@ -35,6 +39,12 @@ class NostrService { } } + Future getRelayInfo(String relayUrl) async { + return await Nostr.instance.services.relays.relayInformationsDocumentNip11( + relayUrl: relayUrl, + ); + } + Future publishEvent(NostrEvent event) async { if (!_isInitialized) { throw Exception('Nostr is not initialized. Call init() first.'); diff --git a/lib/shared/widgets/add_lightning_invoice_widget.dart b/lib/shared/widgets/add_lightning_invoice_widget.dart index 17962ba1..4cb83f26 100644 --- a/lib/shared/widgets/add_lightning_invoice_widget.dart +++ b/lib/shared/widgets/add_lightning_invoice_widget.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class AddLightningInvoiceWidget extends StatefulWidget { final TextEditingController controller; diff --git a/lib/shared/widgets/currency_dropdown.dart b/lib/shared/widgets/currency_dropdown.dart index b70bdf04..e7ac1fdb 100644 --- a/lib/shared/widgets/currency_dropdown.dart +++ b/lib/shared/widgets/currency_dropdown.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; class CurrencyDropdown extends ConsumerWidget { diff --git a/lib/shared/widgets/custom_button.dart b/lib/shared/widgets/custom_button.dart index 1baa16a7..1c1c82c0 100644 --- a/lib/shared/widgets/custom_button.dart +++ b/lib/shared/widgets/custom_button.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../../app/app_theme.dart'; +import '../../core/app_theme.dart'; class CustomButton extends StatelessWidget { final String text; diff --git a/lib/shared/widgets/custom_card.dart b/lib/shared/widgets/custom_card.dart index af1230cd..d8288f98 100644 --- a/lib/shared/widgets/custom_card.dart +++ b/lib/shared/widgets/custom_card.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class CustomCard extends StatelessWidget { final Widget child; diff --git a/lib/shared/widgets/custom_elevated_button.dart b/lib/shared/widgets/custom_elevated_button.dart index 1dbd0c9f..03ca9bc1 100644 --- a/lib/shared/widgets/custom_elevated_button.dart +++ b/lib/shared/widgets/custom_elevated_button.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class CustomElevatedButton extends StatelessWidget { final VoidCallback onPressed; diff --git a/lib/shared/widgets/mostro_app_drawer.dart b/lib/shared/widgets/mostro_app_drawer.dart index 007a1b0f..56c73cf7 100644 --- a/lib/shared/widgets/mostro_app_drawer.dart +++ b/lib/shared/widgets/mostro_app_drawer.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class MostroAppDrawer extends StatelessWidget { const MostroAppDrawer({super.key}); @@ -24,7 +24,7 @@ class MostroAppDrawer extends StatelessWidget { bottom: 8.0, left: 4.0, child: Text( - "Mostro", + 'Mostro', style: TextStyle( color: AppTheme.cream1, fontFamily: GoogleFonts.robotoCondensed().fontFamily, @@ -37,13 +37,25 @@ class MostroAppDrawer extends StatelessWidget { ListTile( title: const Text('Relays'), onTap: () { - context.go('/relays'); + context.push('/relays'); }, ), ListTile( title: const Text('Key Management'), onTap: () { - context.go('/key_management'); + context.push('/key_management'); + }, + ), + ListTile( + title: const Text('App Settings'), + onTap: () { + context.push('/settings'); + }, + ), + ListTile( + title: const Text('About'), + onTap: () { + context.push('/about'); }, ), ], diff --git a/lib/features/home/widgets/order_filter.dart b/lib/shared/widgets/order_filter.dart similarity index 98% rename from lib/features/home/widgets/order_filter.dart rename to lib/shared/widgets/order_filter.dart index abdc12ee..a30e6d38 100644 --- a/lib/features/home/widgets/order_filter.dart +++ b/lib/shared/widgets/order_filter.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class OrderFilter extends StatelessWidget { const OrderFilter({super.key}); diff --git a/lib/shared/widgets/privacy_switch_widget.dart b/lib/shared/widgets/privacy_switch_widget.dart index f9da1f04..dd3c7262 100644 --- a/lib/shared/widgets/privacy_switch_widget.dart +++ b/lib/shared/widgets/privacy_switch_widget.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/app/app_theme.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class PrivacySwitch extends StatefulWidget { final bool initialValue; diff --git a/test/notifiers/add_order_notifier_test.dart b/test/notifiers/add_order_notifier_test.dart index b693b5bc..9f99d8c0 100644 --- a/test/notifiers/add_order_notifier_test.dart +++ b/test/notifiers/add_order_notifier_test.dart @@ -6,7 +6,7 @@ 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/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/features/add_order/providers/add_order_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; @@ -86,13 +86,13 @@ void main() { ); final notifier = - container.read(addOrderNotifierProvider(testUuid).notifier); + container.read(orderNotifierProvider(testUuid).notifier); // Submit the order await notifier.submitOrder(newSellOrder); // Retrieve the final state - final state = container.read(addOrderNotifierProvider(testUuid)); + final state = container.read(orderNotifierProvider(testUuid)); expect(state, isNotNull); final confirmedOrder = state.getPayload(); @@ -153,10 +153,10 @@ void main() { ); final notifier = - container.read(addOrderNotifierProvider(testUuid).notifier); + container.read(orderNotifierProvider(testUuid).notifier); await notifier.submitOrder(newSellRangeOrder); - final state = container.read(addOrderNotifierProvider(testUuid)); + final state = container.read(orderNotifierProvider(testUuid)); expect(state, isNotNull); final confirmedOrder = state.getPayload(); @@ -215,10 +215,10 @@ void main() { ); final notifier = - container.read(addOrderNotifierProvider(testUuid).notifier); + container.read(orderNotifierProvider(testUuid).notifier); await notifier.submitOrder(newBuyOrder); - final state = container.read(addOrderNotifierProvider(testUuid)); + final state = container.read(orderNotifierProvider(testUuid)); expect(state, isNotNull); final confirmedOrder = state.getPayload(); @@ -277,10 +277,10 @@ void main() { ); final notifier = - container.read(addOrderNotifierProvider(testUuid).notifier); + container.read(orderNotifierProvider(testUuid).notifier); await notifier.submitOrder(newBuyOrderWithInvoice); - final state = container.read(addOrderNotifierProvider(testUuid)); + final state = container.read(orderNotifierProvider(testUuid)); expect(state, isNotNull); final confirmedOrder = state.getPayload(); diff --git a/test/notifiers/take_order_notifier_test.dart b/test/notifiers/take_order_notifier_test.dart index c18c3b95..c796a359 100644 --- a/test/notifiers/take_order_notifier_test.dart +++ b/test/notifiers/take_order_notifier_test.dart @@ -5,7 +5,7 @@ import 'package:mockito/mockito.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/features/take_order/providers/order_notifier_providers.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import '../mocks.mocks.dart'; @@ -82,13 +82,13 @@ void main() { // Retrieve the notifier from the provider. final takeBuyNotifier = - container.read(takeBuyOrderNotifierProvider(testOrderId).notifier); + container.read(orderNotifierProvider(testOrderId).notifier); // Invoke the method to simulate taking a buy order. await takeBuyNotifier.takeBuyOrder(testOrderId, 0); // Check that the state has been updated as expected. - final state = container.read(takeBuyOrderNotifierProvider(testOrderId)); + final state = container.read(orderNotifierProvider(testOrderId)); expect(state, isNotNull); // We expect the confirmation action to be "pay-invoice". expect(state.action, equals(Action.payInvoice)); @@ -129,12 +129,12 @@ void main() { ]); final takeSellNotifier = - container.read(takeSellOrderNotifierProvider(testOrderId).notifier); + container.read(orderNotifierProvider(testOrderId).notifier); // Simulate taking a sell order (with amount 0). await takeSellNotifier.takeSellOrder(testOrderId, 0, null); - final state = container.read(takeSellOrderNotifierProvider(testOrderId)); + final state = container.read(orderNotifierProvider(testOrderId)); expect(state, isNotNull); expect(state.action, equals(Action.addInvoice)); final orderPayload = state.getPayload(); @@ -183,12 +183,12 @@ void main() { ]); final takeSellNotifier = - container.read(takeSellOrderNotifierProvider(testOrderId).notifier); + container.read(orderNotifierProvider(testOrderId).notifier); // Simulate taking a sell order with a fiat range (here amount is irrelevant because the payload carries range info). await takeSellNotifier.takeSellOrder(testOrderId, 0, null); - final state = container.read(takeSellOrderNotifierProvider(testOrderId)); + final state = container.read(orderNotifierProvider(testOrderId)); expect(state, isNotNull); expect(state.action, equals(Action.addInvoice)); final orderPayload = state.getPayload(); @@ -221,12 +221,12 @@ void main() { ]); final takeSellNotifier = - container.read(takeSellOrderNotifierProvider(testOrderId).notifier); + container.read(orderNotifierProvider(testOrderId).notifier); // Simulate taking a sell order with a lightning address payload. await takeSellNotifier.takeSellOrder(testOrderId, 0, "mostro_p2p@ln.tips"); - final state = container.read(takeSellOrderNotifierProvider(testOrderId)); + final state = container.read(orderNotifierProvider(testOrderId)); expect(state, isNotNull); expect(state.action, equals(Action.waitingSellerToPay)); diff --git a/test/services/mostro_service_test.dart b/test/services/mostro_service_test.dart index 353bcfc2..0aad874b 100644 --- a/test/services/mostro_service_test.dart +++ b/test/services/mostro_service_test.dart @@ -4,7 +4,7 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:mostro_mobile/app/config.dart'; +import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; From bb371f067d4a129db4c0a0b38268052a1614da26 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 15 Feb 2025 19:08:40 -0800 Subject: [PATCH 048/149] Order Screens complete --- lib/data/repositories/mostro_repository.dart | 15 +- lib/features/mostro/mostro_screen.dart | 7 - .../notfiers/abstract_order_notifier.dart | 6 +- .../order/notfiers/order_notifier.dart | 2 +- .../screens/add_lightning_invoice_screen.dart | 130 +++++++----------- .../screens/pay_lightning_invoice_screen.dart | 77 ++++------- .../order/screens/take_order_screen.dart | 10 +- lib/services/mostro_service.dart | 11 +- .../widgets/add_lightning_invoice_widget.dart | 12 +- .../widgets/clickable_amount_widget.dart | 68 +++++++++ lib/shared/widgets/memory_text_field.dart | 103 ++++++++++++++ .../widgets/pay_lightning_invoice_widget.dart | 6 +- 12 files changed, 278 insertions(+), 169 deletions(-) create mode 100644 lib/shared/widgets/clickable_amount_widget.dart create mode 100644 lib/shared/widgets/memory_text_field.dart diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index 6da3e241..3604c669 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/data/models/payload.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; @@ -64,8 +63,8 @@ class MostroRepository implements OrderRepository { return _subscribe(session); } - Future sendInvoice(String orderId, String invoice) async { - await _mostroService.sendInvoice(orderId, invoice); + Future sendInvoice(String orderId, String invoice, int? amount) async { + await _mostroService.sendInvoice(orderId, invoice, amount); } Future> publishOrder(MostroMessage order) async { @@ -79,9 +78,9 @@ class MostroRepository implements OrderRepository { } Future saveMessages() async { - for (var m in _messages.values.toList()) { + //for (var m in _messages.values.toList()) { //await _messageStorage.addOrder(m); - } + //} } Future saveMessage(MostroMessage message) async { @@ -109,7 +108,7 @@ class MostroRepository implements OrderRepository { } @override - Future addOrder(MostroMessage order) { + Future addOrder(MostroMessage order) { // TODO: implement addOrder throw UnimplementedError(); } @@ -121,13 +120,13 @@ class MostroRepository implements OrderRepository { } @override - Future>> getAllOrders() { + Future> getAllOrders() { // TODO: implement getAllOrders throw UnimplementedError(); } @override - Future updateOrder(MostroMessage order) { + Future updateOrder(MostroMessage order) { // TODO: implement updateOrder throw UnimplementedError(); } diff --git a/lib/features/mostro/mostro_screen.dart b/lib/features/mostro/mostro_screen.dart index ead23c36..1a2567cc 100644 --- a/lib/features/mostro/mostro_screen.dart +++ b/lib/features/mostro/mostro_screen.dart @@ -1,13 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; -import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; @@ -16,10 +13,7 @@ class MostroScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Read the current MostroInstance (here represented by a NostrEvent) final nostrEvent = ref.watch(orderRepositoryProvider).mostroInstance; - - // For messages, assume you have a provider that holds a list of MostroMessage objects. final mostroMessages = ref.watch(mostroRepositoryProvider).allMessages; return nostrEvent == null @@ -71,7 +65,6 @@ class MostroScreen extends ConsumerWidget { ); } - /// Builds a simple ListTile to display an individual MostroMessage. Widget _buildMessageTile(MostroMessage message) { return ListTile( title: Text( diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 9ef7b54a..423b6950 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -93,12 +93,14 @@ class AbstractOrderNotifier extends StateNotifier { 'payment_method': order?.paymentMethod, }); break; + case Action.canceled: + navProvider.go('/'); + notifProvider.showInformation(state.action, values: {'id': orderId}); + break; case Action.fiatSentOk: - case Action.holdInvoicePaymentSettled: case Action.rate: case Action.rateReceived: - case Action.canceled: case Action.cooperativeCancelInitiatedByYou: case Action.disputeInitiatedByYou: case Action.adminSettled: diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 936653c9..50e0c1a7 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -30,7 +30,7 @@ class OrderNotifier extends AbstractOrderNotifier { } Future sendInvoice(String orderId, String invoice, int? amount) async { - await orderRepository.sendInvoice(orderId, invoice); + await orderRepository.sendInvoice(orderId, invoice, amount); } Future cancelOrder() async { diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 8d7b3684..4720144b 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -1,12 +1,10 @@ -import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; -import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart'; class AddLightningInvoiceScreen extends ConsumerStatefulWidget { @@ -23,91 +21,55 @@ class _AddLightningInvoiceScreenState extends ConsumerState { final TextEditingController invoiceController = TextEditingController(); - Future? _orderFuture; - - @override - void initState() { - super.initState(); - final orderRepo = ref.read(orderRepositoryProvider); - _orderFuture = orderRepo.getOrderById(widget.orderId); - } - @override Widget build(BuildContext context) { + final order = ref.read(orderNotifierProvider(widget.orderId)); + + final amount = order.getPayload()?.amount; + return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar(title: 'Add Lightning Invoice'), - body: FutureBuilder( - future: _orderFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } else if (snapshot.hasError) { - return Center( - child: Text( - 'Failed to load order: ${snapshot.error}', - style: const TextStyle(color: Colors.red), - ), - ); - } else { - final order = snapshot.data; - if (order == null) { - return const Center( - child: Text( - 'Order not found', - style: TextStyle(color: Colors.white), - ), - ); - } - - final amount = order.amount; - return CustomCard( - padding: const EdgeInsets.all(16), - child: Material( - color: AppTheme.dark1, - child: Padding( - padding: const EdgeInsets.all(16), - child: AddLightningInvoiceWidget( - controller: invoiceController, - onSubmit: () async { - final invoice = invoiceController.text.trim(); - if (invoice.isNotEmpty) { - final orderRepo = ref.read(orderRepositoryProvider); - try { - // Here you would call your method to send or update the invoice. - - context.go('/'); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Failed to update invoice: ${e.toString()}'), - ), - ); - } - } - }, - onCancel: () async { - final orderRepo = ref.read(orderRepositoryProvider); - try { - await orderRepo.deleteOrder(order.id!); - if (!mounted) return; - context.go('/'); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text('Failed to cancel order: ${e.toString()}'), - ), - ); - } - }, - ), - ), - ), - ); - } - }, + body: CustomCard( + padding: const EdgeInsets.all(16), + child: Material( + color: AppTheme.dark1, + child: Padding( + padding: const EdgeInsets.all(16), + child: AddLightningInvoiceWidget( + controller: invoiceController, + onSubmit: () async { + final invoice = invoiceController.text.trim(); + if (invoice.isNotEmpty) { + final orderNotifier = + ref.read(orderNotifierProvider(widget.orderId).notifier); + try { + await orderNotifier.sendInvoice(widget.orderId, invoice, amount); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('Failed to update invoice: ${e.toString()}'), + ), + ); + } + } + }, + onCancel: () async { + final orderNotifier = ref.read(orderNotifierProvider(widget.orderId).notifier); + try { + await orderNotifier.cancelOrder(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to cancel order: ${e.toString()}'), + ), + ); + } + }, amount: amount!, + ), + ), + ), ), ); } diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index f0ba6f26..7515f78e 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -1,9 +1,10 @@ -import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/data/models/payment_request.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; -import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/widgets/pay_lightning_invoice_widget.dart'; class PayLightningInvoiceScreen extends ConsumerStatefulWidget { @@ -18,56 +19,32 @@ class PayLightningInvoiceScreen extends ConsumerStatefulWidget { class _PayLightningInvoiceScreenState extends ConsumerState { - Future? _orderFuture; - - @override - void initState() { - super.initState(); - // Kick off async load from the Encrypted DB - final orderRepo = ref.read(orderRepositoryProvider); - _orderFuture = orderRepo.getOrderById(widget.orderId); - } - @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.dark1, - appBar: OrderAppBar(title: 'Pay Lightning Invoice'), - body: FutureBuilder( - future: _orderFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - // Show a loading indicator while fetching - return const Center(child: CircularProgressIndicator()); - } else if (snapshot.hasError) { - // Error retrieving order - return Center( - child: Text( - 'Failed to load order: ${snapshot.error}', - style: const TextStyle(color: Colors.red), - ), - ); - } else { - final order = snapshot.data; - // If the order isn't found or buyerInvoice is null/empty - if (order == null) { - return const Center( - child: Text( - 'Invalid payment request.', - style: TextStyle(color: Colors.white, fontSize: 18), - ), - ); - } - - // We have a valid LN invoice in order.buyerInvoice - final lnInvoice = ''; - // order.buyerInvoice!; + final order = ref.read(orderNotifierProvider(widget.orderId)); + final lnInvoice = order.getPayload()?.lnInvoice ?? ''; + final orderNotifier = + ref.read(orderNotifierProvider(widget.orderId).notifier); - return PayLightningInvoiceWidget( - onSubmit: () async {}, onCancel: () async {}, lnInvoice: lnInvoice); - } - }, - ), - ); + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: OrderAppBar(title: 'Pay Lightning Invoice'), + body: CustomCard( + padding: const EdgeInsets.all(16), + child: Material( + color: AppTheme.dark2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PayLightningInvoiceWidget( + onSubmit: () async {}, + onCancel: () async { + await orderNotifier.cancelOrder(); + }, + lnInvoice: lnInvoice), + ], + ), + ), + )); } } diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index d93ad8a4..0d4b3491 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -19,7 +19,7 @@ class TakeOrderScreen extends ConsumerWidget { final OrderType orderType; final TextEditingController _fiatAmountController = TextEditingController(); final TextEditingController _lndAddressController = TextEditingController(); - + final TextTheme textTheme = AppTheme.theme.textTheme; TakeOrderScreen({super.key, required this.orderId, required this.orderType}); @override @@ -82,11 +82,7 @@ class TakeOrderScreen extends ConsumerWidget { children: [ Text( '${order.fiatAmount} ${order.currency} (${order.premium}%)', - style: const TextStyle( - color: AppTheme.cream1, - fontSize: 18, - fontWeight: FontWeight.bold, - ), + style: textTheme.displayLarge, ), Text( '${order.amount} sats', @@ -158,10 +154,10 @@ class TakeOrderScreen extends ConsumerWidget { child: const Text('CANCEL'), ), const SizedBox(width: 16), + // Take Order ElevatedButton( onPressed: () async { final fiatAmount = int.tryParse(_fiatAmountController.text.trim()); - if (orderType == OrderType.buy) { await orderDetailsNotifier.takeBuyOrder(orderId, fiatAmount); } else { diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 90d177de..e2bf99b1 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -10,6 +10,7 @@ import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; class MostroService { final NostrService _nostrService; @@ -44,6 +45,12 @@ class MostroService { if (msgMap.containsKey('order')) { final msg = MostroMessage.fromJson(msgMap['order']); + + if (msg.action == actions.Action.canceled) { + await _sessionManager.deleteSession(session.keyIndex); + return msg; + } + if (session.orderId == null && msg.id != null) { session.orderId = msg.id; await _sessionManager.saveSession(session); @@ -83,12 +90,12 @@ class MostroService { return session; } - Future sendInvoice(String orderId, String invoice) async { + Future sendInvoice(String orderId, String invoice, int? amount) async { final content = newMessage(Action.addInvoice, orderId, payload: { 'payment_request': [ null, invoice, - null, + amount, ] }); await sendMessage(orderId, Config.mostroPubKey, content); diff --git a/lib/shared/widgets/add_lightning_invoice_widget.dart b/lib/shared/widgets/add_lightning_invoice_widget.dart index 4cb83f26..b7531097 100644 --- a/lib/shared/widgets/add_lightning_invoice_widget.dart +++ b/lib/shared/widgets/add_lightning_invoice_widget.dart @@ -1,16 +1,19 @@ import 'package:flutter/material.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/shared/widgets/clickable_amount_widget.dart'; class AddLightningInvoiceWidget extends StatefulWidget { final TextEditingController controller; final VoidCallback onSubmit; final VoidCallback onCancel; + final int amount; const AddLightningInvoiceWidget({ super.key, required this.controller, required this.onSubmit, required this.onCancel, + required this.amount, }); @override @@ -19,16 +22,15 @@ class AddLightningInvoiceWidget extends StatefulWidget { } class _AddLightningInvoiceWidgetState extends State { - - @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - "Please enter a Lightning Invoice:", - style: const TextStyle(color: AppTheme.cream1, fontSize: 16), + ClickableAmountText( + leftText: 'Please enter a Lightning Invoice for: ', + amount: '${widget.amount}', + rightText: ' sats', ), const SizedBox(height: 8), TextFormField( diff --git a/lib/shared/widgets/clickable_amount_widget.dart b/lib/shared/widgets/clickable_amount_widget.dart new file mode 100644 index 00000000..c45ef13b --- /dev/null +++ b/lib/shared/widgets/clickable_amount_widget.dart @@ -0,0 +1,68 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class ClickableAmountText extends StatefulWidget { + final String leftText; + final String amount; + final String rightText; + + const ClickableAmountText({ + super.key, + required this.leftText, + required this.amount, + required this.rightText, + }); + + @override + State createState() => _ClickableAmountTextState(); +} + +class _ClickableAmountTextState extends State { + late TapGestureRecognizer _tapRecognizer; + + @override + void initState() { + super.initState(); + _tapRecognizer = TapGestureRecognizer()..onTap = _handleTap; + } + + @override + void dispose() { + _tapRecognizer.dispose(); + super.dispose(); + } + + void _handleTap() async { + await Clipboard.setData(ClipboardData(text: widget.amount)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Amount ${widget.amount} copied to clipboard'), + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + return RichText( + text: TextSpan( + style: DefaultTextStyle.of(context) + .style + .copyWith(fontSize: 16, color: Colors.white), + children: [ + TextSpan(text: widget.leftText), + TextSpan( + text: widget.amount, + style: const TextStyle( + color: Colors.blue, + decoration: TextDecoration.underline, + ), + recognizer: _tapRecognizer, + ), + TextSpan(text: widget.rightText), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/memory_text_field.dart b/lib/shared/widgets/memory_text_field.dart new file mode 100644 index 00000000..c0910600 --- /dev/null +++ b/lib/shared/widgets/memory_text_field.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; // Replace with your SharedPreferencesAsync import if needed + +class MemoryTextField extends StatefulWidget { + /// The label for the text field. + final String label; + /// A unique key string for persisting the history of inputs. + final String historyKey; + /// An optional callback that fires whenever the text changes. + final ValueChanged? onChanged; + + const MemoryTextField({ + super.key, + required this.label, + required this.historyKey, + this.onChanged, + }); + + @override + MemoryTextFieldState createState() => MemoryTextFieldState(); +} + +class MemoryTextFieldState extends State { + final TextEditingController _controller = TextEditingController(); + // In-memory list for storing previously entered values. + List _history = []; + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + Future _loadHistory() async { + final prefs = await SharedPreferences.getInstance(); + final historyJson = prefs.getString(widget.historyKey); + if (historyJson != null) { + final List list = jsonDecode(historyJson); + setState(() { + _history = list.cast(); + }); + } + } + + Future _saveHistory() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(widget.historyKey, jsonEncode(_history)); + } + + void _handleSubmitted(String value) { + if (value.isNotEmpty && !_history.contains(value)) { + setState(() { + _history.add(value); + }); + _saveHistory(); + } + } + + @override + Widget build(BuildContext context) { + return Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + return _history.where((String option) => + option.toLowerCase().contains(textEditingValue.text.toLowerCase())); + }, + onSelected: (String selection) { + _controller.text = selection; + if (widget.onChanged != null) { + widget.onChanged!(selection); + } + }, + fieldViewBuilder: (BuildContext context, + TextEditingController fieldTextEditingController, + FocusNode fieldFocusNode, + VoidCallback onFieldSubmitted) { + // Synchronize the controller values. + _controller.value = fieldTextEditingController.value; + return TextFormField( + controller: fieldTextEditingController, + focusNode: fieldFocusNode, + decoration: InputDecoration( + labelText: widget.label, + ), + onChanged: widget.onChanged, + onFieldSubmitted: (value) { + _handleSubmitted(value); + onFieldSubmitted(); + }, + ); + }, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/shared/widgets/pay_lightning_invoice_widget.dart b/lib/shared/widgets/pay_lightning_invoice_widget.dart index 74365c24..65d91b59 100644 --- a/lib/shared/widgets/pay_lightning_invoice_widget.dart +++ b/lib/shared/widgets/pay_lightning_invoice_widget.dart @@ -23,7 +23,7 @@ class _PayLightningInvoiceWidgetState extends State { @override Widget build(BuildContext context) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ const Text( 'Pay this invoice to continue the exchange', @@ -78,12 +78,12 @@ class _PayLightningInvoiceWidgetState extends State { borderRadius: BorderRadius.circular(20), ), ), - onPressed: widget.onCancel, + onPressed: widget.onSubmit, child: const Text('OPEN WALLET'), ), const SizedBox(height: 20), ElevatedButton( - onPressed: widget.onSubmit, + onPressed: widget.onCancel, style: ElevatedButton.styleFrom( backgroundColor: Colors.red, shape: RoundedRectangleBorder( From e30068bcafa6ab3d25cf9e66965b07c72b5917db Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sun, 16 Feb 2025 00:59:42 -0800 Subject: [PATCH 049/149] Updated launch icons --- android/app/build.gradle | 2 +- android/app/src/main/AndroidManifest.xml | 4 +- .../main/res/mipmap-hdpi/launcher_icon.png | Bin 0 -> 4316 bytes .../main/res/mipmap-mdpi/launcher_icon.png | Bin 0 -> 2749 bytes .../main/res/mipmap-xhdpi/launcher_icon.png | Bin 0 -> 5980 bytes .../main/res/mipmap-xxhdpi/launcher_icon.png | Bin 0 -> 9456 bytes .../main/res/mipmap-xxxhdpi/launcher_icon.png | Bin 0 -> 12875 bytes ios/Runner/Info.plist | 4 +- lib/core/app.dart | 1 + lib/data/models/range_amount.dart | 6 +- .../notification_controller.dart | 282 -------- .../notifications/notification_page.dart | 218 ------ .../order/screens/add_order_screen.dart | 72 +- .../order/screens/take_order_screen.dart | 24 +- lib/features/order/widgets/buyer_info.dart | 2 +- lib/features/order/widgets/seller_info.dart | 9 +- lib/features/relays/relay.dart | 2 +- lib/features/relays/relays_notifier.dart | 35 +- lib/features/relays/relays_provider.dart | 6 +- lib/features/relays/relays_screen.dart | 8 +- lib/features/settings/about_screen.dart | 31 +- lib/features/settings/settings.dart | 2 +- lib/features/settings/settings_notifier.dart | 4 +- .../trades/screens/trades_detail_screen.dart | 2 +- lib/main.dart | 15 +- lib/notifiers/nostr_service_notifier.dart | 15 - .../open_orders_repository_notifier.dart | 12 - lib/services/nostr_service.dart | 18 +- .../providers/nostr_service_provider.dart | 15 +- lib/shared/widgets/currency_text_field.dart | 3 + linux/CMakeLists.txt | 2 +- linux/flutter/generated_plugin_registrant.cc | 4 - linux/flutter/generated_plugins.cmake | 1 - linux/my_application.cc | 4 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 - macos/Runner/Configs/AppInfo.xcconfig | 2 +- pubspec.lock | 16 - pubspec.yaml | 24 +- test/mocks.dart | 1 - test/mocks.mocks.dart | 637 ++++++++---------- test/services/mostro_service_test.mocks.dart | 461 ++++++------- web/index.html | 2 +- .../flutter/generated_plugin_registrant.cc | 3 - windows/flutter/generated_plugins.cmake | 1 - windows/runner/main.cpp | 2 +- 45 files changed, 632 insertions(+), 1322 deletions(-) create mode 100644 android/app/src/main/res/mipmap-hdpi/launcher_icon.png create mode 100644 android/app/src/main/res/mipmap-mdpi/launcher_icon.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/launcher_icon.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png delete mode 100644 lib/features/notifications/notification_controller.dart delete mode 100644 lib/features/notifications/notification_page.dart delete mode 100644 lib/notifiers/nostr_service_notifier.dart delete mode 100644 lib/notifiers/open_orders_repository_notifier.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 6233e994..9ba0ff47 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -8,7 +8,7 @@ plugins { android { namespace = "com.example.mostro_mobile" compileSdk = 35 // flutter.compileSdkVersion - ndkVersion = "25.1.8937393" //flutter.ndkVersion + ndkVersion = "26.3.11579264" //flutter.ndkVersion compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 395c8d74..9debb7ea 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:icon="@mipmap/launcher_icon"> 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 KMNUMnLSTZe(MeqZ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b8311066e4ad3502e112c7fc42189fa5de474e2e GIT binary patch literal 2749 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 D$DlXn literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a1637808b77691d835dcccd47e53ce3ba877ebeb GIT binary patch literal 5980 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


_dx7#W8#mW8W#QS#_x0U^7SqXg{vkl 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 z0zHiw+*CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Mostro Mobile + Mostro P2P CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - mostro_mobile + Mostro P2P CFBundlePackageType APPL CFBundleShortVersionString diff --git a/lib/core/app.dart b/lib/core/app.dart index 17f21b42..41621e1a 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -47,6 +47,7 @@ class MostroApp extends ConsumerWidget { }, loading: () => const MaterialApp( home: Scaffold( + backgroundColor: AppTheme.dark1, body: Center(child: CircularProgressIndicator()), ), ), diff --git a/lib/data/models/range_amount.dart b/lib/data/models/range_amount.dart index 7401f7bf..ecf73139 100644 --- a/lib/data/models/range_amount.dart +++ b/lib/data/models/range_amount.dart @@ -10,11 +10,7 @@ class RangeAmount { '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.'); - } + final min = int.tryParse(fa[1]) ?? 0; int? max; if (fa.length > 2) { diff --git a/lib/features/notifications/notification_controller.dart b/lib/features/notifications/notification_controller.dart deleted file mode 100644 index f5730f06..00000000 --- a/lib/features/notifications/notification_controller.dart +++ /dev/null @@ -1,282 +0,0 @@ -import 'dart:isolate'; -import 'dart:ui'; - -import 'package:awesome_notifications/awesome_notifications.dart'; -import 'package:flutter/material.dart'; -import 'package:mostro_mobile/core/app.dart'; -import 'package:http/http.dart' as http; - -class NotificationController { - static ReceivedAction? initialAction; - - /// ********************************************* - /// INITIALIZATIONS - /// ********************************************* - /// - static Future initializeLocalNotifications() async { - await AwesomeNotifications().initialize( - null, //'resource://drawable/res_app_icon',// - [ - NotificationChannel( - channelKey: 'alerts', - channelName: 'Alerts', - channelDescription: 'Notification tests as alerts', - playSound: true, - onlyAlertOnce: true, - groupAlertBehavior: GroupAlertBehavior.Children, - importance: NotificationImportance.High, - defaultPrivacy: NotificationPrivacy.Private, - defaultColor: Colors.deepPurple, - ledColor: Colors.deepPurple) - ], - debug: true); - - // Get initial notification action is optional - initialAction = await AwesomeNotifications() - .getInitialNotificationAction(removeFromActionEvents: false); - } - - static ReceivePort? receivePort; - static Future initializeIsolateReceivePort() async { - receivePort = ReceivePort('Notification action port in main isolate') - ..listen( - (silentData) => onActionReceivedImplementationMethod(silentData)); - - // This initialization only happens on main isolate - IsolateNameServer.registerPortWithName( - receivePort!.sendPort, 'notification_action_port'); - } - - /// ********************************************* - /// NOTIFICATION EVENTS LISTENER - /// ********************************************* - /// Notifications events are only delivered after call this method - static Future startListeningNotificationEvents() async { - AwesomeNotifications() - .setListeners(onActionReceivedMethod: onActionReceivedMethod); - } - - /// ********************************************* - /// NOTIFICATION EVENTS - /// ********************************************* - /// - @pragma('vm:entry-point') - static Future onActionReceivedMethod( - ReceivedAction receivedAction) async { - if (receivedAction.actionType == ActionType.SilentAction || - receivedAction.actionType == ActionType.SilentBackgroundAction) { - // For background actions, you must hold the execution until the end - print( - 'Message sent via notification input: "${receivedAction.buttonKeyInput}"'); - await executeLongTaskInBackground(); - } else { - // this process is only necessary when you need to redirect the user - // to a new page or use a valid context, since parallel isolates do not - // have valid context, so you need redirect the execution to main isolate - if (receivePort == null) { - print( - 'onActionReceivedMethod was called inside a parallel dart isolate.'); - SendPort? sendPort = - IsolateNameServer.lookupPortByName('notification_action_port'); - - if (sendPort != null) { - print('Redirecting the execution to main isolate process.'); - sendPort.send(receivedAction); - return; - } - } - - return onActionReceivedImplementationMethod(receivedAction); - } - } - - static Future onActionReceivedImplementationMethod( - ReceivedAction receivedAction) async { - MostroApp.navigatorKey.currentState?.pushNamedAndRemoveUntil( - '/notification-page', - (route) => - (route.settings.name != '/notification-page') || route.isFirst, - arguments: receivedAction); - } - - /// ********************************************* - /// REQUESTING NOTIFICATION PERMISSIONS - /// ********************************************* - /// - static Future displayNotificationRationale() async { - bool userAuthorized = false; - BuildContext context = MostroApp.navigatorKey.currentContext!; - await showDialog( - context: context, - builder: (BuildContext ctx) { - return AlertDialog( - title: Text('Get Notified!', - style: Theme.of(context).textTheme.titleLarge), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: Image.asset( - 'assets/images/animated-bell.gif', - height: MediaQuery.of(context).size.height * 0.3, - fit: BoxFit.fitWidth, - ), - ), - ], - ), - const SizedBox(height: 20), - const Text( - 'Allow Awesome Notifications to send you beautiful notifications!'), - ], - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - }, - child: Text( - 'Deny', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(color: Colors.red), - )), - TextButton( - onPressed: () async { - userAuthorized = true; - Navigator.of(ctx).pop(); - }, - child: Text( - 'Allow', - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(color: Colors.deepPurple), - )), - ], - ); - }); - return userAuthorized && - await AwesomeNotifications().requestPermissionToSendNotifications(); - } - - /// ********************************************* - /// BACKGROUND TASKS TEST - /// ********************************************* - static Future executeLongTaskInBackground() async { - print("starting long task"); - await Future.delayed(const Duration(seconds: 4)); - final url = Uri.parse("http://google.com"); - final re = await http.get(url); - print(re.body); - print("long task done"); - } - - /// ********************************************* - /// NOTIFICATION CREATION METHODS - /// ********************************************* - /// - static Future createNewNotification() async { - bool isAllowed = await AwesomeNotifications().isNotificationAllowed(); - if (!isAllowed) isAllowed = await displayNotificationRationale(); - if (!isAllowed) return; - - await AwesomeNotifications().createNotification( - content: NotificationContent( - id: -1, // -1 is replaced by a random number - channelKey: 'alerts', - title: 'Huston! The eagle has landed!', - body: - "A small step for a man, but a giant leap to Flutter's community!", - bigPicture: 'https://storage.googleapis.com/cms-storage-bucket/d406c736e7c4c57f5f61.png', - largeIcon: 'https://storage.googleapis.com/cms-storage-bucket/0dbfcc7a59cd1cf16282.png', - //'asset://assets/images/balloons-in-sky.jpg', - notificationLayout: NotificationLayout.BigPicture, - payload: {'notificationId': '1234567890'}), - actionButtons: [ - NotificationActionButton(key: 'REDIRECT', label: 'Redirect'), - NotificationActionButton( - key: 'REPLY', - label: 'Reply Message', - requireInputText: true, - actionType: ActionType.SilentAction), - NotificationActionButton( - key: 'DISMISS', - label: 'Dismiss', - actionType: ActionType.DismissAction, - isDangerousOption: true) - ]); - } - - static Future scheduleNewNotification() async { - bool isAllowed = await AwesomeNotifications().isNotificationAllowed(); - if (!isAllowed) isAllowed = await displayNotificationRationale(); - if (!isAllowed) return; - - await myNotifyScheduleInHours( - title: 'test', - msg: 'test message', - heroThumbUrl: - 'https://storage.googleapis.com/cms-storage-bucket/d406c736e7c4c57f5f61.png', - hoursFromNow: 5, - username: 'test user', - repeatNotif: false); - } - - static Future resetBadgeCounter() async { - await AwesomeNotifications().resetGlobalBadge(); - } - - static Future cancelNotifications() async { - await AwesomeNotifications().cancelAll(); - } -} - - -Future myNotifyScheduleInHours({ - required int hoursFromNow, - required String heroThumbUrl, - required String username, - required String title, - required String msg, - bool repeatNotif = false, -}) async { - var nowDate = DateTime.now().add(Duration(hours: hoursFromNow, seconds: 5)); - await AwesomeNotifications().createNotification( - schedule: NotificationCalendar( - //weekday: nowDate.day, - hour: nowDate.hour, - minute: 0, - second: nowDate.second, - repeats: repeatNotif, - //allowWhileIdle: true, - ), - // schedule: NotificationCalendar.fromDate( - // date: DateTime.now().add(const Duration(seconds: 10))), - content: NotificationContent( - id: -1, - channelKey: 'basic_channel', - title: '${Emojis.food_bowl_with_spoon} $title', - body: '$username, $msg', - bigPicture: heroThumbUrl, - notificationLayout: NotificationLayout.BigPicture, - //actionType : ActionType.DismissAction, - color: Colors.black, - backgroundColor: Colors.black, - // customSound: 'resource://raw/notif', - payload: {'actPag': 'myAct', 'actType': 'food', 'username': username}, - ), - actionButtons: [ - NotificationActionButton( - key: 'NOW', - label: 'btnAct1', - ), - NotificationActionButton( - key: 'LATER', - label: 'btnAct2', - ), - ], - ); -} diff --git a/lib/features/notifications/notification_page.dart b/lib/features/notifications/notification_page.dart deleted file mode 100644 index 500d6f7a..00000000 --- a/lib/features/notifications/notification_page.dart +++ /dev/null @@ -1,218 +0,0 @@ -import 'package:awesome_notifications/awesome_notifications.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:palette_generator/palette_generator.dart'; - -/// ********************************************* -/// NOTIFICATION PAGE -/// ********************************************* -class NotificationPage extends StatefulWidget { - const NotificationPage({ - super.key, - required this.receivedAction, - }); - - final ReceivedAction receivedAction; - - @override - NotificationPageState createState() => NotificationPageState(); -} - -class NotificationPageState extends State { - bool get hasTitle => widget.receivedAction.title?.isNotEmpty ?? false; - bool get hasBody => widget.receivedAction.body?.isNotEmpty ?? false; - bool get hasLargeIcon => widget.receivedAction.largeIconImage != null; - bool get hasBigPicture => widget.receivedAction.bigPictureImage != null; - - double bigPictureSize = 0.0; - double largeIconSize = 0.0; - bool isTotallyCollapsed = false; - bool bigPictureIsPredominantlyWhite = true; - - ScrollController scrollController = ScrollController(); - - Future isImagePredominantlyWhite(ImageProvider imageProvider) async { - final paletteGenerator = - await PaletteGenerator.fromImageProvider(imageProvider); - final dominantColor = - paletteGenerator.dominantColor?.color ?? Colors.transparent; - return dominantColor.computeLuminance() > 0.5; - } - - @override - void initState() { - super.initState(); - scrollController.addListener(_scrollListener); - - if (hasBigPicture) { - isImagePredominantlyWhite(widget.receivedAction.bigPictureImage!) - .then((isPredominantlyWhite) => setState(() { - bigPictureIsPredominantlyWhite = isPredominantlyWhite; - })); - } - } - - void _scrollListener() { - bool pastScrollLimit = scrollController.position.pixels >= - scrollController.position.maxScrollExtent - 240; - - if (!hasBigPicture) { - isTotallyCollapsed = true; - return; - } - - if (isTotallyCollapsed) { - if (!pastScrollLimit) { - setState(() { - isTotallyCollapsed = false; - }); - } - } else { - if (pastScrollLimit) { - setState(() { - isTotallyCollapsed = true; - }); - } - } - } - - @override - Widget build(BuildContext context) { - bigPictureSize = MediaQuery.of(context).size.height * .4; - largeIconSize = - MediaQuery.of(context).size.height * (hasBigPicture ? .16 : .2); - - if (!hasBigPicture) { - isTotallyCollapsed = true; - } - - return Scaffold( - body: CustomScrollView( - controller: scrollController, - physics: const BouncingScrollPhysics(), - slivers: [ - SliverAppBar( - elevation: 0, - centerTitle: true, - leading: IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back_ios_rounded, - color: isTotallyCollapsed || bigPictureIsPredominantlyWhite - ? Colors.black - : Colors.white, - ), - ), - systemOverlayStyle: - isTotallyCollapsed || bigPictureIsPredominantlyWhite - ? SystemUiOverlayStyle.dark - : SystemUiOverlayStyle.light, - expandedHeight: hasBigPicture - ? bigPictureSize + (hasLargeIcon ? 40 : 0) - : (hasLargeIcon) - ? largeIconSize + 10 - : MediaQuery.of(context).padding.top + 28, - backgroundColor: Colors.transparent, - stretch: true, - flexibleSpace: FlexibleSpaceBar( - stretchModes: const [StretchMode.zoomBackground], - centerTitle: true, - expandedTitleScale: 1, - collapseMode: CollapseMode.pin, - title: (!hasLargeIcon) - ? null - : Stack(children: [ - Positioned( - bottom: 0, - left: 16, - right: 16, - child: Row( - mainAxisAlignment: hasBigPicture - ? MainAxisAlignment.start - : MainAxisAlignment.center, - children: [ - SizedBox( - height: largeIconSize, - width: largeIconSize, - child: ClipRRect( - borderRadius: BorderRadius.all( - Radius.circular(largeIconSize)), - child: FadeInImage( - placeholder: const NetworkImage( - 'https://cdn.syncfusion.com/content/images/common/placeholder.gif'), - image: widget.receivedAction.largeIconImage!, - fit: BoxFit.cover, - ), - ), - ), - ], - ), - ), - ]), - background: hasBigPicture - ? Padding( - padding: EdgeInsets.only(bottom: hasLargeIcon ? 60 : 20), - child: FadeInImage( - placeholder: const NetworkImage( - 'https://cdn.syncfusion.com/content/images/common/placeholder.gif'), - height: bigPictureSize, - width: MediaQuery.of(context).size.width, - image: widget.receivedAction.bigPictureImage!, - fit: BoxFit.cover, - ), - ) - : null, - ), - ), - SliverList( - delegate: SliverChildListDelegate( - [ - Padding( - padding: - const EdgeInsets.only(bottom: 20.0, left: 20, right: 20), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - text: TextSpan(children: [ - if (hasTitle) - TextSpan( - text: widget.receivedAction.title!, - style: Theme.of(context).textTheme.titleLarge, - ), - if (hasBody) - WidgetSpan( - child: Padding( - padding: EdgeInsets.only( - top: hasTitle ? 16.0 : 0.0, - ), - child: SizedBox( - width: MediaQuery.of(context).size.width, - child: Text( - widget.receivedAction.bodyWithoutHtml ?? - '', - style: Theme.of(context) - .textTheme - .bodyMedium)), - ), - ), - ]), - ), - ], - ), - ), - Container( - color: Colors.black12, - padding: const EdgeInsets.all(20), - width: MediaQuery.of(context).size.width, - child: Text(widget.receivedAction.toString()), - ), - ], - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 13ef00fb..17ed71aa 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -172,16 +172,23 @@ class _AddOrderScreenState extends ConsumerState { // 2) fiat amount _buildDisabledWrapper( enabled: _isEnabled, - child: CurrencyTextField( - key: const ValueKey('fiatAmountField'), - controller: _fiatAmountController, - label: 'Fiat amount', - onChanged: (parsed) { - setState(() { - _minFiatAmount = parsed.$1; - _maxFiatAmount = parsed.$2; - }); - }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.dark1, + borderRadius: BorderRadius.circular(8), + ), + child: CurrencyTextField( + key: const ValueKey('fiatAmountField'), + controller: _fiatAmountController, + label: 'Fiat amount', + onChanged: (parsed) { + setState(() { + _minFiatAmount = parsed.$1; + _maxFiatAmount = parsed.$2; + }); + }, + ), ), ), @@ -261,16 +268,23 @@ class _AddOrderScreenState extends ConsumerState { // 2) fiat amount _buildDisabledWrapper( enabled: _isEnabled, - child: CurrencyTextField( - key: const ValueKey('fiatAmountField'), - controller: _fiatAmountController, - label: 'Fiat amount', - onChanged: (parsed) { - setState(() { - _minFiatAmount = parsed.$1; - _maxFiatAmount = parsed.$2; - }); - }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.dark1, + borderRadius: BorderRadius.circular(8), + ), + child: CurrencyTextField( + key: const ValueKey('fiatAmountField'), + controller: _fiatAmountController, + label: 'Fiat amount', + onChanged: (parsed) { + setState(() { + _minFiatAmount = parsed.$1; + _maxFiatAmount = parsed.$2; + }); + }, + ), ), ), @@ -340,8 +354,8 @@ class _AddOrderScreenState extends ConsumerState { /// REUSABLE TEXT FIELD /// Widget _buildTextField( - String label, Key key, TextEditingController controller, {bool nullable = false, - IconData? suffix}) { + String label, Key key, TextEditingController controller, + {bool nullable = false, IconData? suffix}) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( @@ -359,12 +373,14 @@ class _AddOrderScreenState extends ConsumerState { suffixIcon: suffix != null ? Icon(suffix, color: AppTheme.grey2) : null, ), - validator: nullable ? null : (value) { - if (value == null || value.isEmpty) { - return 'Please enter a value'; - } - return null; - }, + validator: nullable + ? null + : (value) { + if (value == null || value.isEmpty) { + return 'Please enter a value'; + } + return null; + }, ), ); } diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 0d4b3491..2262eac1 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -2,6 +2,7 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; @@ -9,7 +10,6 @@ import 'package:mostro_mobile/features/order/providers/order_notifier_provider.d import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/features/order/widgets/seller_info.dart'; import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; -import 'package:mostro_mobile/shared/widgets/exchange_rate_widget.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; @@ -49,8 +49,6 @@ class TakeOrderScreen extends ConsumerWidget { const SizedBox(height: 16), _buildSellerAmount(ref, order), const SizedBox(height: 16), - ExchangeRateWidget(currency: order.currency!), - const SizedBox(height: 16), if ((orderType == OrderType.sell && order.amount != '0') || order.fiatAmount.maximum != null) _buildBuyerAmount(int.tryParse(order.amount!)), @@ -72,7 +70,7 @@ class TakeOrderScreen extends ConsumerWidget { loading: () => const CircularProgressIndicator(), error: (error, _) => Text('Exchange rate error: $error'), data: (exchangeRate) { - // Example usage: exchangeRate might be a double or something + final sats = (100000000 / exchangeRate) * order.fiatAmount.minimum; return CustomCard( padding: const EdgeInsets.all(16), child: Row( @@ -85,8 +83,18 @@ class TakeOrderScreen extends ConsumerWidget { style: textTheme.displayLarge, ), Text( - '${order.amount} sats', - style: const TextStyle(color: AppTheme.grey2), + '${NumberFormat.currency( + symbol: '', + decimalDigits: 2, + ).format(sats)} sats', + style: const TextStyle(color: AppTheme.cream1), + ), + Text( + '1 BTC = ${NumberFormat.currency( + symbol: '', + decimalDigits: 2, + ).format(exchangeRate)} ${order.currency}', + style: const TextStyle(color: Colors.grey), ), ], ) @@ -120,11 +128,7 @@ class TakeOrderScreen extends ConsumerWidget { controller: _lndAddressController, style: const TextStyle(color: AppTheme.cream1), decoration: InputDecoration( - border: InputBorder.none, labelText: "Enter a Lightning Address", - labelStyle: const TextStyle(color: AppTheme.grey2), - filled: true, - fillColor: AppTheme.dark1, ), validator: (value) { if (value == null || value.isEmpty) { diff --git a/lib/features/order/widgets/buyer_info.dart b/lib/features/order/widgets/buyer_info.dart index 716cdffb..6fe8f9b6 100644 --- a/lib/features/order/widgets/buyer_info.dart +++ b/lib/features/order/widgets/buyer_info.dart @@ -14,7 +14,7 @@ class BuyerInfo extends StatelessWidget { children: [ const CircleAvatar( backgroundColor: AppTheme.grey2, - child: Text('S', style: TextStyle(color: AppTheme.cream1)), + foregroundImage: AssetImage('assets/images/launcher-icon.png'), ), const SizedBox(width: 12), Expanded( diff --git a/lib/features/order/widgets/seller_info.dart b/lib/features/order/widgets/seller_info.dart index 492c5620..e9a05e2b 100644 --- a/lib/features/order/widgets/seller_info.dart +++ b/lib/features/order/widgets/seller_info.dart @@ -14,7 +14,7 @@ class SellerInfo extends StatelessWidget { children: [ const CircleAvatar( backgroundColor: AppTheme.grey2, - child: Text('S', style: TextStyle(color: AppTheme.cream1)), + foregroundImage: AssetImage('assets/images/launcher-icon.png'), ), const SizedBox(width: 12), Expanded( @@ -31,13 +31,6 @@ class SellerInfo extends StatelessWidget { ], ), ), - TextButton( - onPressed: () { - // Implement review logic - }, - child: const Text('Read reviews', - style: TextStyle(color: AppTheme.mostroGreen)), - ), ], ); } diff --git a/lib/features/relays/relay.dart b/lib/features/relays/relay.dart index b118956f..08ec2184 100644 --- a/lib/features/relays/relay.dart +++ b/lib/features/relays/relay.dart @@ -4,7 +4,7 @@ class Relay { Relay({ required this.url, - this.isHealthy = false, + this.isHealthy = true, }); Relay copyWith({String? url, bool? isHealthy}) { diff --git a/lib/features/relays/relays_notifier.dart b/lib/features/relays/relays_notifier.dart index 70d18e25..b082f81f 100644 --- a/lib/features/relays/relays_notifier.dart +++ b/lib/features/relays/relays_notifier.dart @@ -1,34 +1,22 @@ -import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:mostro_mobile/core/config.dart'; // Assumes Config.initialRelays exists. +import 'package:mostro_mobile/features/settings/settings_notifier.dart'; import 'relay.dart'; class RelaysNotifier extends StateNotifier> { - final SharedPreferencesAsync sharedPreferences; - static const _storageKey = 'relays'; + final SettingsNotifier settings; - RelaysNotifier(this.sharedPreferences) : super([]) { + RelaysNotifier(this.settings) : super([]) { _loadRelays(); } - Future _loadRelays() async { - final saved = await sharedPreferences.getString(_storageKey); - if (saved != null) { - final List jsonList = json.decode(saved) as List; - state = jsonList.map((e) => Relay.fromJson(e as Map)).toList(); - } else { - // Use the initial relay list from Config (assumed to be List) - state = Config.nostrRelays - .map((url) => Relay(url: url, isHealthy: true)) - .toList(); - await _saveRelays(); - } + void _loadRelays() { + final saved = settings.state; + state = saved.relays.map((url) => Relay(url: url)).toList(); } Future _saveRelays() async { - final jsonString = json.encode(state.map((r) => r.toJson()).toList()); - await sharedPreferences.setString(_storageKey, jsonString); + final relays = state.map((r) => r.url).toList(); + await settings.updateRelays(relays); } Future addRelay(Relay relay) async { @@ -36,8 +24,8 @@ class RelaysNotifier extends StateNotifier> { await _saveRelays(); } - Future updateRelay(Relay updatedRelay) async { - state = state.map((r) => r.url == updatedRelay.url ? updatedRelay : r).toList(); + Future updateRelay(Relay oldRelay, Relay updatedRelay) async { + state = state.map((r) => r.url == oldRelay.url ? updatedRelay : r).toList(); await _saveRelays(); } @@ -46,9 +34,6 @@ class RelaysNotifier extends StateNotifier> { await _saveRelays(); } - /// For now, this simply sets all relays to “healthy.” - /// In a real app, you’d ping each relay (or use some health endpoint) - /// and update its status accordingly. Future refreshRelayHealth() async { state = state.map((r) => r.copyWith(isHealthy: true)).toList(); await _saveRelays(); diff --git a/lib/features/relays/relays_provider.dart b/lib/features/relays/relays_provider.dart index 94d9a052..639fcd87 100644 --- a/lib/features/relays/relays_provider.dart +++ b/lib/features/relays/relays_provider.dart @@ -1,9 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'relays_notifier.dart'; import 'relay.dart'; final relaysProvider = StateNotifierProvider>((ref) { - final prefs = ref.watch(sharedPreferencesProvider); // Assume you have this provider defined. - return RelaysNotifier(prefs); + final settings = ref.watch(settingsProvider.notifier); // Assume you have this provider defined. + return RelaysNotifier(settings); }); diff --git a/lib/features/relays/relays_screen.dart b/lib/features/relays/relays_screen.dart index 483d4a5c..7a24cebf 100644 --- a/lib/features/relays/relays_screen.dart +++ b/lib/features/relays/relays_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'relays_provider.dart'; import 'relay.dart'; @@ -11,6 +12,9 @@ class RelaysScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + + final settings = ref.watch(settingsProvider); + final relays = ref.watch(relaysProvider); return Scaffold( appBar: AppBar( @@ -30,7 +34,7 @@ class RelaysScreen extends ConsumerWidget { backgroundColor: AppTheme.dark1, body: ListView.builder( padding: const EdgeInsets.all(16), - itemCount: relays.length, + itemCount: settings.relays.length, itemBuilder: (context, index) { final relay = relays[index]; return Card( @@ -130,7 +134,7 @@ void _showEditDialog(BuildContext context, Relay relay, WidgetRef ref) { final newUrl = controller.text.trim(); if (newUrl.isNotEmpty && newUrl != relay.url) { final updatedRelay = relay.copyWith(url: newUrl); - ref.read(relaysProvider.notifier).updateRelay(updatedRelay); + ref.read(relaysProvider.notifier).updateRelay(relay, updatedRelay); } Navigator.pop(dialogContext); }, diff --git a/lib/features/settings/about_screen.dart b/lib/features/settings/about_screen.dart index 2b10ef15..2de2ee28 100644 --- a/lib/features/settings/about_screen.dart +++ b/lib/features/settings/about_screen.dart @@ -42,7 +42,6 @@ class AboutScreen extends ConsumerWidget { body: Padding( padding: const EdgeInsets.all(16.0), child: Container( - margin: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppTheme.dark2, borderRadius: BorderRadius.circular(20), @@ -81,59 +80,55 @@ class AboutScreen extends ConsumerWidget { return CustomCard( color: AppTheme.dark1, - padding: EdgeInsets.all(24), + padding: EdgeInsets.all(16), child: Column( - spacing: 3.0, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Pubkey: ${instance.pubKey}', ), - const SizedBox(height: 4), + const SizedBox(height: 3), Text( 'Version: ${instance.mostroVersion}', ), - const SizedBox(height: 4), - Text( - 'Commit Hash: ${instance.commitHash}', - ), - const SizedBox(height: 4), + const SizedBox(height: 3), + // Text('Commit Hash: ${instance.commitHash}',), + // const SizedBox(height: 3), Text( 'Max Order Amount: ${formatter.format(instance.maxOrderAmount)}', ), - const SizedBox(height: 4), + const SizedBox(height: 3), Text( 'Min Order Amount: ${formatter.format(instance.minOrderAmount)}', ), - const SizedBox(height: 4), + const SizedBox(height: 3), Text( 'Expiration Hours: ${instance.expirationHours}', ), - const SizedBox(height: 4), + const SizedBox(height: 3), Text( 'Expiration Seconds: ${instance.expirationSeconds}', ), - const SizedBox(height: 4), + const SizedBox(height: 3), Text( 'Fee: ${instance.fee}', ), - const SizedBox(height: 4), + const SizedBox(height: 3), Text( 'Proof of Work: ${instance.pow}', ), - const SizedBox(height: 4), + const SizedBox(height: 3), Text( 'Hold Invoice Expiration Window: ${instance.holdInvoiceExpirationWindow}', ), - const SizedBox(height: 4), + const SizedBox(height: 3), Text( 'Hold Invoice CLTV Delta: ${instance.holdInvoiceCltvDelta}', ), - const SizedBox(height: 4), + const SizedBox(height: 3), Text( 'Invoice Expiration Window: ${instance.invoiceExpirationWindow}', ), - const SizedBox(height: 4), ], )); } diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart index 77199070..ffee4a67 100644 --- a/lib/features/settings/settings.dart +++ b/lib/features/settings/settings.dart @@ -19,7 +19,7 @@ class Settings { factory Settings.fromJson(Map json) { return Settings( relays: (json['relays'] as List?)?.cast() ?? [], - fullPrivacyMode: json[' fullPrivacyMode'] as bool, + fullPrivacyMode: json['fullPrivacyMode'] as bool, ); } } diff --git a/lib/features/settings/settings_notifier.dart b/lib/features/settings/settings_notifier.dart index d48170a3..ba91273b 100644 --- a/lib/features/settings/settings_notifier.dart +++ b/lib/features/settings/settings_notifier.dart @@ -10,7 +10,7 @@ class SettingsNotifier extends StateNotifier { static final String _storageKey = SharedPreferencesKeys.appSettings.value; SettingsNotifier(this._prefs) : super(_defaultSettings()) { - _init(); + //init(); } static Settings _defaultSettings() { @@ -20,7 +20,7 @@ class SettingsNotifier extends StateNotifier { ); } - Future _init() async { + Future init() async { final settingsJson = await _prefs.getString(_storageKey); if (settingsJson != null) { try { diff --git a/lib/features/trades/screens/trades_detail_screen.dart b/lib/features/trades/screens/trades_detail_screen.dart index ef4e9816..a99824d6 100644 --- a/lib/features/trades/screens/trades_detail_screen.dart +++ b/lib/features/trades/screens/trades_detail_screen.dart @@ -33,7 +33,7 @@ class TradeDetailScreen extends ConsumerWidget { ), title: Text( 'TRADE DETAIL', - style: AppTheme.theme.textTheme.displayLarge, + style: AppTheme.theme.textTheme.displaySmall, ), ), body: orderAsyncValue.when( diff --git a/lib/main.dart b/lib/main.dart index 0c791b13..33f904fe 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,10 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mostro_mobile/core/app.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; -import 'package:mostro_mobile/features/notifications/notification_controller.dart'; +import 'package:mostro_mobile/features/settings/settings_notifier.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; -import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; +import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/storage_providers.dart'; import 'package:mostro_mobile/shared/utils/biometrics_helper.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -14,20 +15,22 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - await NotificationController.initializeLocalNotifications(); - await NotificationController.initializeIsolateReceivePort(); - final nostrService = NostrService(); await nostrService.init(); + final biometricsHelper = BiometricsHelper(); final sharedPreferences = SharedPreferencesAsync(); final secureStorage = const FlutterSecureStorage(); final database = await openMostroDatabase(); + final settings = SettingsNotifier(sharedPreferences); + await settings.init(); + runApp( ProviderScope( overrides: [ - nostrServicerProvider.overrideWithValue(nostrService), + nostrProvider.overrideWithValue(nostrService), + settingsProvider.overrideWith((b) => settings), biometricsHelperProvider.overrideWithValue(biometricsHelper), sharedPreferencesProvider.overrideWithValue(sharedPreferences), secureStorageProvider.overrideWithValue(secureStorage), diff --git a/lib/notifiers/nostr_service_notifier.dart b/lib/notifiers/nostr_service_notifier.dart deleted file mode 100644 index 95e55c81..00000000 --- a/lib/notifiers/nostr_service_notifier.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/services/nostr_service.dart'; - -class NostrServiceNotifier extends AsyncNotifier { - @override - Future build() async { - final service = NostrService(); - await service.init(); - return service; - } -} - -final nostrServiceProvider = Provider((ref) { - return NostrService()..init(); -}); \ No newline at end of file diff --git a/lib/notifiers/open_orders_repository_notifier.dart b/lib/notifiers/open_orders_repository_notifier.dart deleted file mode 100644 index a5657027..00000000 --- a/lib/notifiers/open_orders_repository_notifier.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; -import 'package:mostro_mobile/notifiers/nostr_service_notifier.dart'; - -class OpenOrdersRepositoryNotifier extends AsyncNotifier { - @override - Future build() async { - final nostrService = ref.watch(nostrServiceProvider); - return OpenOrdersRepository(nostrService); - } -} - diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 625ece71..e9191a75 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -2,9 +2,11 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:dart_nostr/nostr/model/relay_informations.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class NostrService { + Settings? settings; static final NostrService _instance = NostrService._internal(); factory NostrService() => _instance; NostrService._internal(); @@ -14,19 +16,21 @@ class NostrService { bool _isInitialized = false; Future init() async { - if (_isInitialized) return; - + //if (_isInitialized) return; _nostr = Nostr.instance; try { await _nostr.services.relays.init( - relaysUrl: Config.nostrRelays, + relaysUrl: settings?.relays ?? Config.nostrRelays, connectionTimeout: Config.nostrConnectionTimeout, onRelayListening: (relay, url, channel) { - _logger.i('Connected to relay: $url'); + _logger.i('Connected to relay: $relay'); }, onRelayConnectionError: (relay, error, channel) { _logger.w('Failed to connect to relay $relay: $error'); }, + onRelayConnectionDone: (relay, socket) { + _logger.i('Connection to relay: $relay via $socket is done'); + }, retryOnClose: true, retryOnError: true, shouldReconnectToRelayOnNotice: true, @@ -39,6 +43,12 @@ class NostrService { } } + Future updateSettings(Settings settings) async { + _logger.i('Updating settings...'); + this.settings = settings.copyWith(); + await init(); + } + Future getRelayInfo(String relayUrl) async { return await Nostr.instance.services.relays.relayInformationsDocumentNip11( relayUrl: relayUrl, diff --git a/lib/shared/providers/nostr_service_provider.dart b/lib/shared/providers/nostr_service_provider.dart index 7d177cda..ad81918f 100644 --- a/lib/shared/providers/nostr_service_provider.dart +++ b/lib/shared/providers/nostr_service_provider.dart @@ -1,6 +1,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; +final nostrProvider = Provider((ref) { + throw UnimplementedError(); +}); + final nostrServicerProvider = Provider((ref) { - return NostrService(); + final service = ref.watch(nostrProvider); + + ref.listen(settingsProvider, (previous, next) { + service.updateSettings(next); + }); + + return service; }); + diff --git a/lib/shared/widgets/currency_text_field.dart b/lib/shared/widgets/currency_text_field.dart index fb48d49d..3fc3cb16 100644 --- a/lib/shared/widgets/currency_text_field.dart +++ b/lib/shared/widgets/currency_text_field.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class CurrencyTextField extends StatefulWidget { final TextEditingController controller; @@ -45,6 +46,7 @@ class CurrencyTextFieldState extends State { Widget build(BuildContext context) { return TextFormField( controller: widget.controller, + style: const TextStyle(color: AppTheme.cream1), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^[0-9]*-?[0-9]*$')), @@ -52,6 +54,7 @@ class CurrencyTextFieldState extends State { decoration: InputDecoration( border: InputBorder.none, labelText: widget.label, + labelStyle: const TextStyle(color: AppTheme.grey2), ), onChanged: (value) { final parsed = _parseInput(); diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index df814f8e..a031e99a 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -4,7 +4,7 @@ project(runner LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "mostro_mobile") +set(BINARY_NAME "mostro_client") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID set(APPLICATION_ID "com.example.mostro_mobile") diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e6fa1ba4..d0e7f797 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,13 +6,9 @@ #include "generated_plugin_registrant.h" -#include #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) awesome_notifications_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "AwesomeNotificationsPlugin"); - awesome_notifications_plugin_register_with_registrar(awesome_notifications_registrar); 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 1e8c4c63..b29e9ba0 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - awesome_notifications flutter_secure_storage_linux ) diff --git a/linux/my_application.cc b/linux/my_application.cc index 4464c3bc..d48e3b27 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "mostro_mobile"); + gtk_header_bar_set_title(header_bar, "Mostro P2P"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "mostro_mobile"); + gtk_window_set_title(window, "Mostro P2P"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 2a3ce279..46b0a8b7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,7 +5,6 @@ import FlutterMacOS import Foundation -import awesome_notifications import flutter_secure_storage_darwin import local_auth_darwin import mobile_scanner @@ -13,7 +12,6 @@ import path_provider_foundation import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - AwesomeNotificationsPlugin.register(with: registry.registrar(forPlugin: "AwesomeNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 86b8d3fb..d8ce5f91 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,7 +5,7 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = mostro_mobile +PRODUCT_NAME = Mostro P2P // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.example.mostroMobile diff --git a/pubspec.lock b/pubspec.lock index ab2ecc3a..cfc0e0ed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" - awesome_notifications: - dependency: "direct main" - description: - name: awesome_notifications - sha256: d051ffb694a53da216ff13d02c8ec645d75320048262f7e6b3c1d95a4f54c902 - url: "https://pub.dev" - source: hosted - version: "0.10.0" base58check: dependency: transitive description: @@ -765,14 +757,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" - palette_generator: - dependency: "direct main" - description: - name: palette_generator - sha256: "0b20245c451f14a5ca0818ab7a377765162389f8e8f0db361cceabf0fed9d1ea" - url: "https://pub.dev" - source: hosted - version: "0.3.3+5" path: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 204a25b0..a0614883 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,15 +50,6 @@ dependencies: elliptic: ^0.3.11 intl: ^0.19.0 uuid: ^4.5.1 - - flutter_localizations: - sdk: flutter - - nip44: - git: - url: https://github.com/chebizarro/dart-nip44.git - ref: master - flutter_secure_storage: ^10.0.0-beta.4 go_router: ^14.6.2 bip39: ^1.0.6 @@ -67,12 +58,19 @@ dependencies: flutter_launcher_icons: ^0.14.2 bip32: ^2.0.0 path: ^1.9.0 - awesome_notifications: ^0.10.0 - palette_generator: ^0.3.3+5 sembast: ^3.8.2 path_provider: ^2.1.5 mobile_scanner: ^6.0.6 + flutter_localizations: + sdk: flutter + + nip44: + git: + url: https://github.com/chebizarro/dart-nip44.git + ref: master + + dev_dependencies: flutter_test: sdk: flutter @@ -129,8 +127,8 @@ flutter: flutter_intl: enabled: true -flutter_icons: - android: true +flutter_launcher_icons: + android: "launcher_icon" ios: true image_path: "assets/images/launcher-icon.png" remove_alpha_ios: true diff --git a/test/mocks.dart b/test/mocks.dart index f3455965..b83ac088 100644 --- a/test/mocks.dart +++ b/test/mocks.dart @@ -1,4 +1,3 @@ -// test/mocks.dart import 'package:mockito/annotations.dart'; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index ec83cfab..83f8e5a8 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in mostro_mobile/test/mocks.dart. // Do not manually edit this file. @@ -24,29 +24,20 @@ import 'package:mostro_mobile/services/mostro_service.dart' as _i4; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class class _FakeSession_0 extends _i1.SmartFake implements _i2.Session { - _FakeSession_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeSession_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeNostrEvent_1 extends _i1.SmartFake implements _i3.NostrEvent { - _FakeNostrEvent_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeNostrEvent_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [MostroService]. @@ -60,19 +51,15 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { @override _i5.Stream<_i6.MostroMessage<_i7.Payload>> subscribe(_i2.Session? session) => (super.noSuchMethod( - Invocation.method( - #subscribe, - [session], - ), - returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ) as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); + Invocation.method(#subscribe, [session]), + returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ) + as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); @override _i2.Session? getSessionByOrderId(String? orderId) => - (super.noSuchMethod(Invocation.method( - #getSessionByOrderId, - [orderId], - )) as _i2.Session?); + (super.noSuchMethod(Invocation.method(#getSessionByOrderId, [orderId])) + as _i2.Session?); @override _i5.Future<_i2.Session> takeSellOrder( @@ -81,114 +68,74 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { String? lnAddress, ) => (super.noSuchMethod( - Invocation.method( - #takeSellOrder, - [ - orderId, - amount, - lnAddress, - ], - ), - returnValue: _i5.Future<_i2.Session>.value(_FakeSession_0( - this, - Invocation.method( - #takeSellOrder, - [ - orderId, - amount, - lnAddress, - ], - ), - )), - ) as _i5.Future<_i2.Session>); - - @override - _i5.Future sendInvoice( - String? orderId, - String? invoice, - ) => + Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), + returnValue: _i5.Future<_i2.Session>.value( + _FakeSession_0( + this, + Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), + ), + ), + ) + as _i5.Future<_i2.Session>); + + @override + _i5.Future sendInvoice(String? orderId, String? invoice, int? amount) => (super.noSuchMethod( - Invocation.method( - #sendInvoice, - [ - orderId, - invoice, - ], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#sendInvoice, [orderId, invoice, amount]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future<_i2.Session> takeBuyOrder( - String? orderId, - int? amount, - ) => + _i5.Future<_i2.Session> takeBuyOrder(String? orderId, int? amount) => (super.noSuchMethod( - Invocation.method( - #takeBuyOrder, - [ - orderId, - amount, - ], - ), - returnValue: _i5.Future<_i2.Session>.value(_FakeSession_0( - this, - Invocation.method( - #takeBuyOrder, - [ - orderId, - amount, - ], - ), - )), - ) as _i5.Future<_i2.Session>); + Invocation.method(#takeBuyOrder, [orderId, amount]), + returnValue: _i5.Future<_i2.Session>.value( + _FakeSession_0( + this, + Invocation.method(#takeBuyOrder, [orderId, amount]), + ), + ), + ) + as _i5.Future<_i2.Session>); @override _i5.Future<_i2.Session> publishOrder(_i6.MostroMessage<_i7.Payload>? order) => (super.noSuchMethod( - Invocation.method( - #publishOrder, - [order], - ), - returnValue: _i5.Future<_i2.Session>.value(_FakeSession_0( - this, - Invocation.method( - #publishOrder, - [order], - ), - )), - ) as _i5.Future<_i2.Session>); - - @override - _i5.Future cancelOrder(String? orderId) => (super.noSuchMethod( - Invocation.method( - #cancelOrder, - [orderId], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future sendFiatSent(String? orderId) => (super.noSuchMethod( - Invocation.method( - #sendFiatSent, - [orderId], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future releaseOrder(String? orderId) => (super.noSuchMethod( - Invocation.method( - #releaseOrder, - [orderId], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#publishOrder, [order]), + returnValue: _i5.Future<_i2.Session>.value( + _FakeSession_0(this, Invocation.method(#publishOrder, [order])), + ), + ) + as _i5.Future<_i2.Session>); + + @override + _i5.Future cancelOrder(String? orderId) => + (super.noSuchMethod( + Invocation.method(#cancelOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future sendFiatSent(String? orderId) => + (super.noSuchMethod( + Invocation.method(#sendFiatSent, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future releaseOrder(String? orderId) => + (super.noSuchMethod( + Invocation.method(#releaseOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override Map newMessage( @@ -197,16 +144,14 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { Object? payload, }) => (super.noSuchMethod( - Invocation.method( - #newMessage, - [ - actionType, - orderId, - ], - {#payload: payload}, - ), - returnValue: {}, - ) as Map); + Invocation.method( + #newMessage, + [actionType, orderId], + {#payload: payload}, + ), + returnValue: {}, + ) + as Map); @override _i5.Future sendMessage( @@ -215,17 +160,11 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { Map? content, ) => (super.noSuchMethod( - Invocation.method( - #sendMessage, - [ - orderId, - receiverPubkey, - content, - ], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#sendMessage, [orderId, receiverPubkey, content]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override _i5.Future<_i3.NostrEvent> createNIP59Event( @@ -234,26 +173,23 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { _i2.Session? session, ) => (super.noSuchMethod( - Invocation.method( - #createNIP59Event, - [ - content, - recipientPubKey, - session, - ], - ), - returnValue: _i5.Future<_i3.NostrEvent>.value(_FakeNostrEvent_1( - this, - Invocation.method( - #createNIP59Event, - [ + Invocation.method(#createNIP59Event, [ content, recipientPubKey, session, - ], - ), - )), - ) as _i5.Future<_i3.NostrEvent>); + ]), + returnValue: _i5.Future<_i3.NostrEvent>.value( + _FakeNostrEvent_1( + this, + Invocation.method(#createNIP59Event, [ + content, + recipientPubKey, + session, + ]), + ), + ), + ) + as _i5.Future<_i3.NostrEvent>); } /// A class which mocks [MostroRepository]. @@ -265,31 +201,30 @@ class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { } @override - List<_i6.MostroMessage<_i7.Payload>> get allMessages => (super.noSuchMethod( - Invocation.getter(#allMessages), - returnValue: <_i6.MostroMessage<_i7.Payload>>[], - ) as List<_i6.MostroMessage<_i7.Payload>>); + List<_i6.MostroMessage<_i7.Payload>> get allMessages => + (super.noSuchMethod( + Invocation.getter(#allMessages), + returnValue: <_i6.MostroMessage<_i7.Payload>>[], + ) + as List<_i6.MostroMessage<_i7.Payload>>); @override _i5.Future<_i6.MostroMessage<_i7.Payload>?> getOrderById(String? orderId) => (super.noSuchMethod( - Invocation.method( - #getOrderById, - [orderId], - ), - returnValue: _i5.Future<_i6.MostroMessage<_i7.Payload>?>.value(), - ) as _i5.Future<_i6.MostroMessage<_i7.Payload>?>); + Invocation.method(#getOrderById, [orderId]), + returnValue: _i5.Future<_i6.MostroMessage<_i7.Payload>?>.value(), + ) + as _i5.Future<_i6.MostroMessage<_i7.Payload>?>); @override _i5.Stream<_i6.MostroMessage<_i7.Payload>> resubscribeOrder( - String? orderId) => + String? orderId, + ) => (super.noSuchMethod( - Invocation.method( - #resubscribeOrder, - [orderId], - ), - returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ) as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); + Invocation.method(#resubscribeOrder, [orderId]), + returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ) + as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); @override _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> takeSellOrder( @@ -298,18 +233,13 @@ class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { String? lnAddress, ) => (super.noSuchMethod( - Invocation.method( - #takeSellOrder, - [ - orderId, - amount, - lnAddress, - ], - ), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty()), - ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ), + ) + as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); @override _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> takeBuyOrder( @@ -317,150 +247,123 @@ class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { int? amount, ) => (super.noSuchMethod( - Invocation.method( - #takeBuyOrder, - [ - orderId, - amount, - ], - ), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty()), - ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + Invocation.method(#takeBuyOrder, [orderId, amount]), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ), + ) + as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); @override - _i5.Future sendInvoice( - String? orderId, - String? invoice, - ) => + _i5.Future sendInvoice(String? orderId, String? invoice, int? amount) => (super.noSuchMethod( - Invocation.method( - #sendInvoice, - [ - orderId, - invoice, - ], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#sendInvoice, [orderId, invoice, amount]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> publishOrder( - _i6.MostroMessage<_i7.Payload>? order) => - (super.noSuchMethod( - Invocation.method( - #publishOrder, - [order], - ), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty()), - ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); - - @override - _i5.Future cancelOrder(String? orderId) => (super.noSuchMethod( - Invocation.method( - #cancelOrder, - [orderId], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future saveMessages() => (super.noSuchMethod( - Invocation.method( - #saveMessages, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i6.MostroMessage<_i7.Payload>? order, + ) => + (super.noSuchMethod( + Invocation.method(#publishOrder, [order]), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ), + ) + as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + + @override + _i5.Future cancelOrder(String? orderId) => + (super.noSuchMethod( + Invocation.method(#cancelOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future saveMessages() => + (super.noSuchMethod( + Invocation.method(#saveMessages, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override _i5.Future saveMessage(_i6.MostroMessage<_i7.Payload>? message) => (super.noSuchMethod( - Invocation.method( - #saveMessage, - [message], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#saveMessage, [message]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future deleteMessage(String? messageId) => (super.noSuchMethod( - Invocation.method( - #deleteMessage, - [messageId], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future deleteMessage(String? messageId) => + (super.noSuchMethod( + Invocation.method(#deleteMessage, [messageId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future loadMessages() => (super.noSuchMethod( - Invocation.method( - #loadMessages, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future loadMessages() => + (super.noSuchMethod( + Invocation.method(#loadMessages, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override void dispose() => super.noSuchMethod( - Invocation.method( - #dispose, - [], - ), - returnValueForMissingStub: null, - ); + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); @override _i5.Future addOrder(_i6.MostroMessage<_i7.Payload>? order) => (super.noSuchMethod( - Invocation.method( - #addOrder, - [order], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#addOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future deleteOrder(String? orderId) => (super.noSuchMethod( - Invocation.method( - #deleteOrder, - [orderId], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future deleteOrder(String? orderId) => + (super.noSuchMethod( + Invocation.method(#deleteOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override _i5.Future>> getAllOrders() => (super.noSuchMethod( - Invocation.method( - #getAllOrders, - [], - ), - returnValue: _i5.Future>>.value( - <_i6.MostroMessage<_i7.Payload>>[]), - ) as _i5.Future>>); + Invocation.method(#getAllOrders, []), + returnValue: _i5.Future>>.value( + <_i6.MostroMessage<_i7.Payload>>[], + ), + ) + as _i5.Future>>); @override _i5.Future updateOrder(_i6.MostroMessage<_i7.Payload>? order) => (super.noSuchMethod( - Invocation.method( - #updateOrder, - [order], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#updateOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); } /// A class which mocks [OpenOrdersRepository]. @@ -473,81 +376,75 @@ class MockOpenOrdersRepository extends _i1.Mock } @override - _i5.Stream> get eventsStream => (super.noSuchMethod( - Invocation.getter(#eventsStream), - returnValue: _i5.Stream>.empty(), - ) as _i5.Stream>); + _i5.Stream> get eventsStream => + (super.noSuchMethod( + Invocation.getter(#eventsStream), + returnValue: _i5.Stream>.empty(), + ) + as _i5.Stream>); @override - List<_i3.NostrEvent> get currentEvents => (super.noSuchMethod( - Invocation.getter(#currentEvents), - returnValue: <_i3.NostrEvent>[], - ) as List<_i3.NostrEvent>); + List<_i3.NostrEvent> get currentEvents => + (super.noSuchMethod( + Invocation.getter(#currentEvents), + returnValue: <_i3.NostrEvent>[], + ) + as List<_i3.NostrEvent>); @override void subscribeToOrders() => super.noSuchMethod( - Invocation.method( - #subscribeToOrders, - [], - ), - returnValueForMissingStub: null, - ); + Invocation.method(#subscribeToOrders, []), + returnValueForMissingStub: null, + ); @override void dispose() => super.noSuchMethod( - Invocation.method( - #dispose, - [], - ), - returnValueForMissingStub: null, - ); + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); @override _i5.Future<_i3.NostrEvent?> getOrderById(String? orderId) => (super.noSuchMethod( - Invocation.method( - #getOrderById, - [orderId], - ), - returnValue: _i5.Future<_i3.NostrEvent?>.value(), - ) as _i5.Future<_i3.NostrEvent?>); - - @override - _i5.Future addOrder(_i3.NostrEvent? order) => (super.noSuchMethod( - Invocation.method( - #addOrder, - [order], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future deleteOrder(String? orderId) => (super.noSuchMethod( - Invocation.method( - #deleteOrder, - [orderId], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future> getAllOrders() => (super.noSuchMethod( - Invocation.method( - #getAllOrders, - [], - ), - returnValue: _i5.Future>.value(<_i3.NostrEvent>[]), - ) as _i5.Future>); - - @override - _i5.Future updateOrder(_i3.NostrEvent? order) => (super.noSuchMethod( - Invocation.method( - #updateOrder, - [order], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#getOrderById, [orderId]), + returnValue: _i5.Future<_i3.NostrEvent?>.value(), + ) + as _i5.Future<_i3.NostrEvent?>); + + @override + _i5.Future addOrder(_i3.NostrEvent? order) => + (super.noSuchMethod( + Invocation.method(#addOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future deleteOrder(String? orderId) => + (super.noSuchMethod( + Invocation.method(#deleteOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future> getAllOrders() => + (super.noSuchMethod( + Invocation.method(#getAllOrders, []), + returnValue: _i5.Future>.value( + <_i3.NostrEvent>[], + ), + ) + as _i5.Future>); + + @override + _i5.Future updateOrder(_i3.NostrEvent? order) => + (super.noSuchMethod( + Invocation.method(#updateOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); } diff --git a/test/services/mostro_service_test.mocks.dart b/test/services/mostro_service_test.mocks.dart index 274277ca..946d82c3 100644 --- a/test/services/mostro_service_test.mocks.dart +++ b/test/services/mostro_service_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in mostro_mobile/test/services/mostro_service_test.dart. // Do not manually edit this file. @@ -6,10 +6,11 @@ import 'dart:async' as _i5; import 'package:dart_nostr/dart_nostr.dart' as _i2; +import 'package:dart_nostr/nostr/model/relay_informations.dart' as _i6; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i6; +import 'package:mockito/src/dummies.dart' as _i7; import 'package:mostro_mobile/data/models/session.dart' as _i3; -import 'package:mostro_mobile/data/repositories/session_manager.dart' as _i7; +import 'package:mostro_mobile/data/repositories/session_manager.dart' as _i8; import 'package:mostro_mobile/services/nostr_service.dart' as _i4; // ignore_for_file: type=lint @@ -20,39 +21,25 @@ import 'package:mostro_mobile/services/nostr_service.dart' as _i4; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class class _FakeNostrKeyPairs_0 extends _i1.SmartFake implements _i2.NostrKeyPairs { - _FakeNostrKeyPairs_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeNostrKeyPairs_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeNostrEvent_1 extends _i1.SmartFake implements _i2.NostrEvent { - _FakeNostrEvent_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeNostrEvent_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } class _FakeSession_2 extends _i1.SmartFake implements _i3.Session { - _FakeSession_2( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); + _FakeSession_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); } /// A class which mocks [NostrService]. @@ -64,96 +51,87 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { } @override - bool get isInitialized => (super.noSuchMethod( - Invocation.getter(#isInitialized), - returnValue: false, - ) as bool); + bool get isInitialized => + (super.noSuchMethod(Invocation.getter(#isInitialized), returnValue: false) + as bool); @override - _i5.Future init() => (super.noSuchMethod( - Invocation.method( - #init, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future init() => + (super.noSuchMethod( + Invocation.method(#init, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future<_i6.RelayInformations?> getRelayInfo(String? relayUrl) => + (super.noSuchMethod( + Invocation.method(#getRelayInfo, [relayUrl]), + returnValue: _i5.Future<_i6.RelayInformations?>.value(), + ) + as _i5.Future<_i6.RelayInformations?>); @override - _i5.Future publishEvent(_i2.NostrEvent? event) => (super.noSuchMethod( - Invocation.method( - #publishEvent, - [event], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future publishEvent(_i2.NostrEvent? event) => + (super.noSuchMethod( + Invocation.method(#publishEvent, [event]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override _i5.Stream<_i2.NostrEvent> subscribeToEvents(_i2.NostrFilter? filter) => (super.noSuchMethod( - Invocation.method( - #subscribeToEvents, - [filter], - ), - returnValue: _i5.Stream<_i2.NostrEvent>.empty(), - ) as _i5.Stream<_i2.NostrEvent>); + Invocation.method(#subscribeToEvents, [filter]), + returnValue: _i5.Stream<_i2.NostrEvent>.empty(), + ) + as _i5.Stream<_i2.NostrEvent>); @override - _i5.Future disconnectFromRelays() => (super.noSuchMethod( - Invocation.method( - #disconnectFromRelays, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future disconnectFromRelays() => + (super.noSuchMethod( + Invocation.method(#disconnectFromRelays, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future<_i2.NostrKeyPairs> generateKeyPair() => (super.noSuchMethod( - Invocation.method( - #generateKeyPair, - [], - ), - returnValue: _i5.Future<_i2.NostrKeyPairs>.value(_FakeNostrKeyPairs_0( - this, - Invocation.method( - #generateKeyPair, - [], - ), - )), - ) as _i5.Future<_i2.NostrKeyPairs>); + _i5.Future<_i2.NostrKeyPairs> generateKeyPair() => + (super.noSuchMethod( + Invocation.method(#generateKeyPair, []), + returnValue: _i5.Future<_i2.NostrKeyPairs>.value( + _FakeNostrKeyPairs_0( + this, + Invocation.method(#generateKeyPair, []), + ), + ), + ) + as _i5.Future<_i2.NostrKeyPairs>); @override _i2.NostrKeyPairs generateKeyPairFromPrivateKey(String? privateKey) => (super.noSuchMethod( - Invocation.method( - #generateKeyPairFromPrivateKey, - [privateKey], - ), - returnValue: _FakeNostrKeyPairs_0( - this, - Invocation.method( - #generateKeyPairFromPrivateKey, - [privateKey], - ), - ), - ) as _i2.NostrKeyPairs); + Invocation.method(#generateKeyPairFromPrivateKey, [privateKey]), + returnValue: _FakeNostrKeyPairs_0( + this, + Invocation.method(#generateKeyPairFromPrivateKey, [privateKey]), + ), + ) + as _i2.NostrKeyPairs); @override - String getMostroPubKey() => (super.noSuchMethod( - Invocation.method( - #getMostroPubKey, - [], - ), - returnValue: _i6.dummyValue( - this, - Invocation.method( - #getMostroPubKey, - [], - ), - ), - ) as String); + String getMostroPubKey() => + (super.noSuchMethod( + Invocation.method(#getMostroPubKey, []), + returnValue: _i7.dummyValue( + this, + Invocation.method(#getMostroPubKey, []), + ), + ) + as String); @override _i5.Future<_i2.NostrEvent> createNIP59Event( @@ -162,26 +140,23 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { String? senderPrivateKey, ) => (super.noSuchMethod( - Invocation.method( - #createNIP59Event, - [ - content, - recipientPubKey, - senderPrivateKey, - ], - ), - returnValue: _i5.Future<_i2.NostrEvent>.value(_FakeNostrEvent_1( - this, - Invocation.method( - #createNIP59Event, - [ + Invocation.method(#createNIP59Event, [ content, recipientPubKey, senderPrivateKey, - ], - ), - )), - ) as _i5.Future<_i2.NostrEvent>); + ]), + returnValue: _i5.Future<_i2.NostrEvent>.value( + _FakeNostrEvent_1( + this, + Invocation.method(#createNIP59Event, [ + content, + recipientPubKey, + senderPrivateKey, + ]), + ), + ), + ) + as _i5.Future<_i2.NostrEvent>); @override _i5.Future<_i2.NostrEvent> decryptNIP59Event( @@ -189,24 +164,15 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { String? privateKey, ) => (super.noSuchMethod( - Invocation.method( - #decryptNIP59Event, - [ - event, - privateKey, - ], - ), - returnValue: _i5.Future<_i2.NostrEvent>.value(_FakeNostrEvent_1( - this, - Invocation.method( - #decryptNIP59Event, - [ - event, - privateKey, - ], - ), - )), - ) as _i5.Future<_i2.NostrEvent>); + Invocation.method(#decryptNIP59Event, [event, privateKey]), + returnValue: _i5.Future<_i2.NostrEvent>.value( + _FakeNostrEvent_1( + this, + Invocation.method(#decryptNIP59Event, [event, privateKey]), + ), + ), + ) + as _i5.Future<_i2.NostrEvent>); @override _i5.Future createRumor( @@ -216,28 +182,25 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { String? content, ) => (super.noSuchMethod( - Invocation.method( - #createRumor, - [ - senderKeyPair, - wrapperKey, - recipientPubKey, - content, - ], - ), - returnValue: _i5.Future.value(_i6.dummyValue( - this, - Invocation.method( - #createRumor, - [ + Invocation.method(#createRumor, [ senderKeyPair, wrapperKey, recipientPubKey, content, - ], - ), - )), - ) as _i5.Future); + ]), + returnValue: _i5.Future.value( + _i7.dummyValue( + this, + Invocation.method(#createRumor, [ + senderKeyPair, + wrapperKey, + recipientPubKey, + content, + ]), + ), + ), + ) + as _i5.Future); @override _i5.Future createSeal( @@ -247,28 +210,25 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { String? encryptedContent, ) => (super.noSuchMethod( - Invocation.method( - #createSeal, - [ - senderKeyPair, - wrapperKey, - recipientPubKey, - encryptedContent, - ], - ), - returnValue: _i5.Future.value(_i6.dummyValue( - this, - Invocation.method( - #createSeal, - [ + Invocation.method(#createSeal, [ senderKeyPair, wrapperKey, recipientPubKey, encryptedContent, - ], - ), - )), - ) as _i5.Future); + ]), + returnValue: _i5.Future.value( + _i7.dummyValue( + this, + Invocation.method(#createSeal, [ + senderKeyPair, + wrapperKey, + recipientPubKey, + encryptedContent, + ]), + ), + ), + ) + as _i5.Future); @override _i5.Future<_i2.NostrEvent> createWrap( @@ -277,127 +237,114 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { String? recipientPubKey, ) => (super.noSuchMethod( - Invocation.method( - #createWrap, - [ - wrapperKeyPair, - sealedContent, - recipientPubKey, - ], - ), - returnValue: _i5.Future<_i2.NostrEvent>.value(_FakeNostrEvent_1( - this, - Invocation.method( - #createWrap, - [ + Invocation.method(#createWrap, [ wrapperKeyPair, sealedContent, recipientPubKey, - ], - ), - )), - ) as _i5.Future<_i2.NostrEvent>); + ]), + returnValue: _i5.Future<_i2.NostrEvent>.value( + _FakeNostrEvent_1( + this, + Invocation.method(#createWrap, [ + wrapperKeyPair, + sealedContent, + recipientPubKey, + ]), + ), + ), + ) + as _i5.Future<_i2.NostrEvent>); } /// A class which mocks [SessionManager]. /// /// See the documentation for Mockito's code generation for more information. -class MockSessionManager extends _i1.Mock implements _i7.SessionManager { +class MockSessionManager extends _i1.Mock implements _i8.SessionManager { MockSessionManager() { _i1.throwOnMissingStub(this); } @override - int get sessionExpirationHours => (super.noSuchMethod( - Invocation.getter(#sessionExpirationHours), - returnValue: 0, - ) as int); + int get sessionExpirationHours => + (super.noSuchMethod( + Invocation.getter(#sessionExpirationHours), + returnValue: 0, + ) + as int); @override - List<_i3.Session> get sessions => (super.noSuchMethod( - Invocation.getter(#sessions), - returnValue: <_i3.Session>[], - ) as List<_i3.Session>); + List<_i3.Session> get sessions => + (super.noSuchMethod( + Invocation.getter(#sessions), + returnValue: <_i3.Session>[], + ) + as List<_i3.Session>); @override - _i5.Future init() => (super.noSuchMethod( - Invocation.method( - #init, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future init() => + (super.noSuchMethod( + Invocation.method(#init, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future<_i3.Session> newSession({String? orderId}) => (super.noSuchMethod( - Invocation.method( - #newSession, - [], - {#orderId: orderId}, - ), - returnValue: _i5.Future<_i3.Session>.value(_FakeSession_2( - this, - Invocation.method( - #newSession, - [], - {#orderId: orderId}, - ), - )), - ) as _i5.Future<_i3.Session>); + _i5.Future<_i3.Session> newSession({String? orderId}) => + (super.noSuchMethod( + Invocation.method(#newSession, [], {#orderId: orderId}), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_2( + this, + Invocation.method(#newSession, [], {#orderId: orderId}), + ), + ), + ) + as _i5.Future<_i3.Session>); @override - _i5.Future saveSession(_i3.Session? session) => (super.noSuchMethod( - Invocation.method( - #saveSession, - [session], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future saveSession(_i3.Session? session) => + (super.noSuchMethod( + Invocation.method(#saveSession, [session]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override _i3.Session? getSessionByOrderId(String? orderId) => - (super.noSuchMethod(Invocation.method( - #getSessionByOrderId, - [orderId], - )) as _i3.Session?); + (super.noSuchMethod(Invocation.method(#getSessionByOrderId, [orderId])) + as _i3.Session?); @override - _i5.Future<_i3.Session?> loadSession(int? keyIndex) => (super.noSuchMethod( - Invocation.method( - #loadSession, - [keyIndex], - ), - returnValue: _i5.Future<_i3.Session?>.value(), - ) as _i5.Future<_i3.Session?>); + _i5.Future<_i3.Session?> loadSession(int? keyIndex) => + (super.noSuchMethod( + Invocation.method(#loadSession, [keyIndex]), + returnValue: _i5.Future<_i3.Session?>.value(), + ) + as _i5.Future<_i3.Session?>); @override - _i5.Future deleteSession(int? sessionId) => (super.noSuchMethod( - Invocation.method( - #deleteSession, - [sessionId], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future deleteSession(int? sessionId) => + (super.noSuchMethod( + Invocation.method(#deleteSession, [sessionId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future clearExpiredSessions() => (super.noSuchMethod( - Invocation.method( - #clearExpiredSessions, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future clearExpiredSessions() => + (super.noSuchMethod( + Invocation.method(#clearExpiredSessions, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override void dispose() => super.noSuchMethod( - Invocation.method( - #dispose, - [], - ), - returnValueForMissingStub: null, - ); + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); } diff --git a/web/index.html b/web/index.html index cf892ecd..2c5728bb 100644 --- a/web/index.html +++ b/web/index.html @@ -29,7 +29,7 @@ - mostro_mobile + Mostro P2P diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 74045e40..011734da 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,13 +6,10 @@ #include "generated_plugin_registrant.h" -#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { - AwesomeNotificationsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("AwesomeNotificationsPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ce91c88c..11485fce 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - awesome_notifications flutter_secure_storage_windows local_auth_windows ) diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 2c9997be..afb01686 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"mostro_mobile", origin, size)) { + if (!window.Create(L"Mostro P2P", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); From 33c41b77d68890401aea451947aeded8d801b4e6 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sun, 16 Feb 2025 23:00:35 -0800 Subject: [PATCH 050/149] Major revisions --- integration_test/new_buy_order_test.dart | 6 +- integration_test/new_sell_order_test.dart | 2 +- lib/core/config.dart | 8 +- lib/data/models/messages.dart | 13 +++ lib/data/models/payment_request.dart | 2 +- lib/data/models/range_amount.dart | 4 +- lib/data/repositories/message_repository.dart | 4 + lib/data/repositories/session_storage.dart | 5 +- lib/features/home/screens/home_screen.dart | 2 +- .../home/widgets/order_list_item.dart | 25 +++--- .../messages/notifiers/messages_notifier.dart | 13 +++ .../providers/messages_list_provider.dart | 2 + .../notfiers/abstract_order_notifier.dart | 10 ++- .../screens/add_lightning_invoice_screen.dart | 2 +- .../order/screens/payment_qr_screen.dart | 86 ------------------ .../order/screens/take_order_screen.dart | 90 +++++++++++++++---- .../trades/screens/trades_screen.dart | 5 +- lib/shared/providers/app_init_provider.dart | 14 ++- .../providers/nostr_service_provider.dart | 3 +- .../widgets/add_lightning_invoice_widget.dart | 4 +- lib/shared/widgets/currency_text_field.dart | 2 - .../widgets/pay_lightning_invoice_widget.dart | 52 ++++++++--- 22 files changed, 199 insertions(+), 155 deletions(-) create mode 100644 lib/data/models/messages.dart create mode 100644 lib/data/repositories/message_repository.dart create mode 100644 lib/features/messages/notifiers/messages_notifier.dart delete mode 100644 lib/features/order/screens/payment_qr_screen.dart diff --git a/integration_test/new_buy_order_test.dart b/integration_test/new_buy_order_test.dart index 24797921..7b90a917 100644 --- a/integration_test/new_buy_order_test.dart +++ b/integration_test/new_buy_order_test.dart @@ -127,7 +127,7 @@ void main() { 'User creates a new BUY order with EUR=10 and SATS=1500 using a lightning invoice', (tester) async { app.main(); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 1)); // Navigate to the “Add Order” screen final createOrderButton = find.byKey(const Key('createOrderButton')); @@ -234,7 +234,7 @@ void main() { 'User creates a new BUY order with EUR=10 at Market rate using a lightning invoice with no amount', (tester) async { app.main(); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 1)); // Navigate to the “Add Order” screen final createOrderButton = find.byKey(const Key('createOrderButton')); @@ -351,7 +351,7 @@ void main() { 'User creates a new BUY order with VES=100 and premium=1 using a LN Address', (tester) async { app.main(); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 1)); // Navigate to the “Add Order” screen final createOrderButton = find.byKey(const Key('createOrderButton')); diff --git a/integration_test/new_sell_order_test.dart b/integration_test/new_sell_order_test.dart index 1557682f..6edfa7ab 100644 --- a/integration_test/new_sell_order_test.dart +++ b/integration_test/new_sell_order_test.dart @@ -126,7 +126,7 @@ void main() { 'User creates a new SELL range order with VES=10-20 and premium=1', (tester) async { app.main(); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 1)); // Navigate to the “Add Order” screen final createOrderButton = find.byKey(const Key('createOrderButton')); diff --git a/lib/core/config.dart b/lib/core/config.dart index bd0098ff..25fe825b 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -3,11 +3,10 @@ import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ - //'ws://127.0.0.1:7000', + //'wss://relay.mostro.network', 'ws://192.168.1.148:7000', + //'ws://127.0.0.1:7000', //'ws://10.0.2.2:7000', // mobile emulator - //'ws://192.168.1.148:7000', - //'wss://relay.mostro.network', ]; // hexkey de Mostro @@ -25,4 +24,7 @@ class Config { // Versión de Mostro static int mostroVersion = 1; + + static int expirationSeconds = 900; + static int expirationHours = 24; } diff --git a/lib/data/models/messages.dart b/lib/data/models/messages.dart new file mode 100644 index 00000000..18f58a2a --- /dev/null +++ b/lib/data/models/messages.dart @@ -0,0 +1,13 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; + +class Messages { + final String buyerPubkey; + final String sellerPubkey; + final List messages = []; + + Messages({required this.buyerPubkey, required this.sellerPubkey}); + + void sortMessages() { + messages.sort((a, b) => a.createdAt!.compareTo(b.createdAt!)); + } +} diff --git a/lib/data/models/payment_request.dart b/lib/data/models/payment_request.dart index 90d03b65..56851447 100644 --- a/lib/data/models/payment_request.dart +++ b/lib/data/models/payment_request.dart @@ -47,7 +47,7 @@ class PaymentRequest implements Payload { final amount = json.length > 2 ? json[2] as int? : null; return PaymentRequest( order: order, - lnInvoice: lnInvoice as String?, + lnInvoice: lnInvoice, amount: amount, ); } diff --git a/lib/data/models/range_amount.dart b/lib/data/models/range_amount.dart index ecf73139..f4977852 100644 --- a/lib/data/models/range_amount.dart +++ b/lib/data/models/range_amount.dart @@ -10,11 +10,11 @@ class RangeAmount { 'List must have at least two elements: a label and a minimum value.'); } - final min = int.tryParse(fa[1]) ?? 0; + final min = double.tryParse(fa[1])?.toInt() ?? 0; int? max; if (fa.length > 2) { - max = int.tryParse(fa[2]); + max = double.tryParse(fa[2])?.toInt(); } return RangeAmount(min, max); diff --git a/lib/data/repositories/message_repository.dart b/lib/data/repositories/message_repository.dart new file mode 100644 index 00000000..7080471c --- /dev/null +++ b/lib/data/repositories/message_repository.dart @@ -0,0 +1,4 @@ +class MessageRepository { + + +} \ No newline at end of file diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index b63e316e..a419c3db 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -19,17 +19,17 @@ class SessionStorage { this._keyManager, ); - /// Retrieves all sessions from the database (decodes them as well). Future> getAllSessions() async { final records = await _store.find(_database); final sessions = []; for (final record in records) { try { final session = await _decodeSession(record.value); + _logger.i('Decoded session ${session.toJson()}'); sessions.add(session); } catch (e) { _logger.e('Error decoding session for key ${record.key}: $e'); - // Optionally handle or remove corrupted records + deleteSession(record.key); } } return sessions; @@ -62,7 +62,6 @@ class SessionStorage { } /// Finds and deletes sessions considered expired, returning a list of deleted IDs. - /// (Keeps domain logic here for simplicity; you could also move it into the Manager.) Future> deleteExpiredSessions( int sessionExpirationHours, int maxBatchSize) async { final now = DateTime.now(); diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 3ecb38fc..3d5b1bfd 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -18,7 +18,7 @@ class HomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final homeStateAsync = ref.watch(homeNotifierProvider); - final homeNotifier = ref.read(homeNotifierProvider.notifier); + final homeNotifier = ref.watch(homeNotifierProvider.notifier); return homeStateAsync.when( data: (homeState) { diff --git a/lib/features/home/widgets/order_list_item.dart b/lib/features/home/widgets/order_list_item.dart index 70753667..df7f3b1d 100644 --- a/lib/features/home/widgets/order_list_item.dart +++ b/lib/features/home/widgets/order_list_item.dart @@ -139,22 +139,27 @@ class OrderListItem extends StatelessWidget { ? order.paymentMethods[0] : 'No payment method'; + String methods = order.paymentMethods.join('\n'); + return Row( children: [ - HeroIcon( - _getPaymentMethodIcon(method), - style: HeroIconStyle.outline, - color: AppTheme.cream1, - size: 16, + Padding( + padding: const EdgeInsets.only(right: 8), + child: HeroIcon( + _getPaymentMethodIcon(method), + style: HeroIconStyle.outline, + color: AppTheme.cream1, + size: 16, + ), ), const SizedBox(width: 4), Flexible( child: Text( - method, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.grey2, - ), - overflow: TextOverflow.ellipsis, + methods, + style: AppTheme.theme.textTheme.bodyMedium?.copyWith( + color: AppTheme.grey2, + ), + overflow: TextOverflow.fade, softWrap: true, ), ), diff --git a/lib/features/messages/notifiers/messages_notifier.dart b/lib/features/messages/notifiers/messages_notifier.dart new file mode 100644 index 00000000..137a08fe --- /dev/null +++ b/lib/features/messages/notifiers/messages_notifier.dart @@ -0,0 +1,13 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/messages.dart'; + +class MessagesNotifier extends StateNotifier { + MessagesNotifier(super.state); + + + Future subscribe(Stream stream) async { + } + + +} \ No newline at end of file diff --git a/lib/features/messages/providers/messages_list_provider.dart b/lib/features/messages/providers/messages_list_provider.dart index acf69d61..4a3c887f 100644 --- a/lib/features/messages/providers/messages_list_provider.dart +++ b/lib/features/messages/providers/messages_list_provider.dart @@ -13,3 +13,5 @@ final messagesDetailNotifierProvider = StateNotifierProvider.family< MessagesDetailNotifier, MessagesDetailState, String>((ref, chatId) { return MessagesDetailNotifier(chatId); }); + + diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 423b6950..948f166a 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; +import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; @@ -77,12 +78,15 @@ class AbstractOrderNotifier extends StateNotifier { navProvider.go('/'); notifProvider.showInformation(state.action, values: { 'id': state.id, - 'expiration_seconds': mostroInstance?.expirationSeconds + 'expiration_seconds': + mostroInstance?.expirationSeconds ?? Config.expirationSeconds, }); break; case Action.waitingBuyerInvoice: - notifProvider.showInformation(state.action, - values: {'expiration_seconds': mostroInstance?.expirationSeconds}); + notifProvider.showInformation(state.action, values: { + 'expiration_seconds': + mostroInstance?.expirationSeconds ?? Config.expirationSeconds, + }); break; case Action.buyerTookOrder: final order = state.getPayload(); diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 4720144b..0aaf3b92 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -33,7 +33,7 @@ class _AddLightningInvoiceScreenState body: CustomCard( padding: const EdgeInsets.all(16), child: Material( - color: AppTheme.dark1, + color: AppTheme.dark2, child: Padding( padding: const EdgeInsets.all(16), child: AddLightningInvoiceWidget( diff --git a/lib/features/order/screens/payment_qr_screen.dart b/lib/features/order/screens/payment_qr_screen.dart deleted file mode 100644 index b51c8d36..00000000 --- a/lib/features/order/screens/payment_qr_screen.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; -import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart' as action; - - -class PaymentQrScreen extends ConsumerWidget { - final String orderId; - - const PaymentQrScreen({super.key, required this.orderId}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(orderNotifierProvider(orderId)); - - return Scaffold( - backgroundColor: const Color(0xFF1D212C), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - title: const Text('PAYMENT', style: TextStyle(color: Colors.white)), - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => context.go('/'), - ), - ), - body: _buildBody(context, ref, state), - bottomNavigationBar: const BottomNavBar(), // from your code - ); - } - - Widget _buildBody(BuildContext context, WidgetRef ref, MostroMessage state) { - switch (state.action) { - case action.Action.notFound: - return const Center(child: CircularProgressIndicator()); - - case action.Action.payInvoice: - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'Pay this invoice to continue the exchange', - style: TextStyle(color: Colors.white), - ), - const SizedBox(height: 20), - - // Possibly insert your QR code or something - // e.g. QrImage(...) - - const SizedBox(height: 20), - Text( - 'Expires in: ', - style: const TextStyle(color: Colors.white), - ), - const SizedBox(height: 20), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8CC541), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - onPressed: () { - //ref.read(paymentQrProvider.notifier).openWallet(); - }, - child: const Text('OPEN WALLET'), - ), - const SizedBox(height: 20), - TextButton( - child: const Text('CANCEL', style: TextStyle(color: Colors.white)), - onPressed: () => context.go('/'), - ), - ], - ); - - default: - return Center( - child: Text('Unknown error: ${state.action}', - style: const TextStyle(color: Colors.white)), - ); - } - } -} diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 2262eac1..7358f11e 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -2,6 +2,7 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:heroicons/heroicons.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; @@ -49,11 +50,12 @@ class TakeOrderScreen extends ConsumerWidget { const SizedBox(height: 16), _buildSellerAmount(ref, order), const SizedBox(height: 16), + _buildPaymentMethod(order), + const SizedBox(height: 16), if ((orderType == OrderType.sell && order.amount != '0') || order.fiatAmount.maximum != null) _buildBuyerAmount(int.tryParse(order.amount!)), _buildLnAddress(), - const SizedBox(height: 16), _buildActionButtons(context, ref, order.orderId!), ], ), @@ -105,6 +107,49 @@ class TakeOrderScreen extends ConsumerWidget { ); } + Widget _buildPaymentMethod(NostrEvent order) { + String method = order.paymentMethods.isNotEmpty + ? order.paymentMethods[0] + : 'No payment method'; + + String methods = order.paymentMethods.join('\n'); + + return CustomCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: HeroIcon( + _getPaymentMethodIcon(method), + style: HeroIconStyle.outline, + color: AppTheme.cream1, + size: 16, + ), + ), + const SizedBox(width: 4), + Flexible( + child: Text( + methods, + style: AppTheme.theme.textTheme.bodyMedium, + ), + ), + ], + )); + } + + HeroIcons _getPaymentMethodIcon(String method) { + switch (method.toLowerCase()) { + case 'wire transfer': + case 'transferencia bancaria': + return HeroIcons.buildingLibrary; + case 'revolut': + return HeroIcons.creditCard; + default: + return HeroIcons.banknotes; + } + } + Widget _buildBuyerAmount(int? amount) { return Column(children: [ CustomCard( @@ -112,7 +157,9 @@ class TakeOrderScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CurrencyTextField(controller: _fiatAmountController, label: 'Fiat'), + CurrencyTextField( + controller: _fiatAmountController, + label: 'Enter a Fiat amount'), const SizedBox(height: 8), ], ), @@ -122,21 +169,32 @@ class TakeOrderScreen extends ConsumerWidget { } Widget _buildLnAddress() { - return CustomCard( - padding: const EdgeInsets.all(16), - child: TextFormField( - controller: _lndAddressController, - style: const TextStyle(color: AppTheme.cream1), - decoration: InputDecoration( - labelText: "Enter a Lightning Address", + return Column( + children: [ + CustomCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _lndAddressController, + style: const TextStyle(color: AppTheme.cream1), + decoration: InputDecoration( + labelText: "Enter a Lightning Address", + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a value'; + } + return null; + }, + ), + const SizedBox(height: 8), + ], + ), ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a value'; - } - return null; - }, - ), + const SizedBox(height: 24), + ], ); } diff --git a/lib/features/trades/screens/trades_screen.dart b/lib/features/trades/screens/trades_screen.dart index a5f12b07..f805f983 100644 --- a/lib/features/trades/screens/trades_screen.dart +++ b/lib/features/trades/screens/trades_screen.dart @@ -16,6 +16,7 @@ class TradesScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tradesAsync = ref.watch(tradesProvider); + final provider = ref.watch(tradesProvider.notifier); return tradesAsync.when( data: (state) { @@ -25,8 +26,7 @@ class TradesScreen extends ConsumerWidget { drawer: const MostroAppDrawer(), body: RefreshIndicator( onRefresh: () async { - // Force a refresh of sessions - //ref.refresh(tradesProvider); + await provider.refresh(); }, child: Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), @@ -109,7 +109,6 @@ class TradesScreen extends ConsumerWidget { ); } - Widget _buildOrderList(TradesState state) { if (state.orders.isEmpty) { return const Center( diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 2121fe6d..ba8da59e 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -4,7 +4,9 @@ import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -18,12 +20,18 @@ final appInitializerProvider = FutureProvider((ref) async { final sessionManager = ref.read(sessionManagerProvider); await sessionManager.init(); + final settings = ref.read(settingsProvider); + final service = ref.read(nostrProvider); + service.updateSettings(settings); + final mostroRepository = ref.read(mostroRepositoryProvider); await mostroRepository.loadMessages(); - for (final msg in mostroRepository.allMessages) { - final orderId = msg.id!; - ref.read(orderNotifierProvider(orderId).notifier); + for (final session in sessionManager.sessions) { + if (session.orderId != null) { + final order = ref.watch(orderNotifierProvider(session.orderId!).notifier); + order.reSubscribe(); + } } }); diff --git a/lib/shared/providers/nostr_service_provider.dart b/lib/shared/providers/nostr_service_provider.dart index ad81918f..3a523a87 100644 --- a/lib/shared/providers/nostr_service_provider.dart +++ b/lib/shared/providers/nostr_service_provider.dart @@ -8,7 +8,7 @@ final nostrProvider = Provider((ref) { }); final nostrServicerProvider = Provider((ref) { - final service = ref.watch(nostrProvider); + final service = ref.read(nostrProvider); ref.listen(settingsProvider, (previous, next) { service.updateSettings(next); @@ -16,4 +16,3 @@ final nostrServicerProvider = Provider((ref) { return service; }); - diff --git a/lib/shared/widgets/add_lightning_invoice_widget.dart b/lib/shared/widgets/add_lightning_invoice_widget.dart index b7531097..89b64232 100644 --- a/lib/shared/widgets/add_lightning_invoice_widget.dart +++ b/lib/shared/widgets/add_lightning_invoice_widget.dart @@ -32,7 +32,7 @@ class _AddLightningInvoiceWidgetState extends State { amount: '${widget.amount}', rightText: ' sats', ), - const SizedBox(height: 8), + const SizedBox(height: 16), TextFormField( key: const Key('invoiceTextField'), controller: widget.controller, @@ -47,7 +47,9 @@ class _AddLightningInvoiceWidgetState extends State { hintStyle: const TextStyle(color: AppTheme.grey2), filled: true, fillColor: AppTheme.dark1, + alignLabelWithHint: true, ), + maxLines: 6, ), const SizedBox(height: 16), Row( diff --git a/lib/shared/widgets/currency_text_field.dart b/lib/shared/widgets/currency_text_field.dart index 3fc3cb16..3a45754b 100644 --- a/lib/shared/widgets/currency_text_field.dart +++ b/lib/shared/widgets/currency_text_field.dart @@ -52,9 +52,7 @@ class CurrencyTextFieldState extends State { FilteringTextInputFormatter.allow(RegExp(r'^[0-9]*-?[0-9]*$')), ], decoration: InputDecoration( - border: InputBorder.none, labelText: widget.label, - labelStyle: const TextStyle(color: AppTheme.grey2), ), onChanged: (value) { final parsed = _parseInput(); diff --git a/lib/shared/widgets/pay_lightning_invoice_widget.dart b/lib/shared/widgets/pay_lightning_invoice_widget.dart index 65d91b59..dd914ce8 100644 --- a/lib/shared/widgets/pay_lightning_invoice_widget.dart +++ b/lib/shared/widgets/pay_lightning_invoice_widget.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:qr_flutter/qr_flutter.dart'; class PayLightningInvoiceWidget extends StatefulWidget { final VoidCallback onSubmit; final VoidCallback onCancel; + final Logger logger = Logger(); final String lnInvoice; - const PayLightningInvoiceWidget({ + PayLightningInvoiceWidget({ super.key, required this.onSubmit, required this.onCancel, @@ -27,18 +31,18 @@ class _PayLightningInvoiceWidgetState extends State { children: [ const Text( 'Pay this invoice to continue the exchange', - style: TextStyle(color: Colors.white, fontSize: 18), + style: TextStyle(color: AppTheme.cream1, fontSize: 18), textAlign: TextAlign.center, ), const SizedBox(height: 20), Container( padding: const EdgeInsets.all(8.0), - color: Colors.white, + color: AppTheme.cream1, child: QrImageView( data: widget.lnInvoice, version: QrVersions.auto, size: 250.0, - backgroundColor: Colors.white, + backgroundColor: AppTheme.cream1, errorStateBuilder: (cxt, err) { return const Center( child: Text( @@ -53,6 +57,8 @@ class _PayLightningInvoiceWidgetState extends State { ElevatedButton.icon( onPressed: () { Clipboard.setData(ClipboardData(text: widget.lnInvoice)); + widget.logger + .i('Copied LN Invoice to clipboard: ${widget.lnInvoice}'); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Invoice copied to clipboard!'), @@ -63,7 +69,7 @@ class _PayLightningInvoiceWidgetState extends State { icon: const Icon(Icons.copy), label: const Text('Copy Invoice'), style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8CC541), + backgroundColor: AppTheme.mostroGreen, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), @@ -73,7 +79,7 @@ class _PayLightningInvoiceWidgetState extends State { // Open Wallet Button ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8CC541), + backgroundColor: AppTheme.mostroGreen, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), @@ -82,15 +88,33 @@ class _PayLightningInvoiceWidgetState extends State { child: const Text('OPEN WALLET'), ), const SizedBox(height: 20), - ElevatedButton( - onPressed: widget.onCancel, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: widget.onCancel, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + child: const Text('CANCEL'), ), - ), - child: const Text('CANCEL'), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + context.go('/'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + child: const Text('DONE'), + ), + ], ), ], ); From 9ed07e4cc6912b163690e872cffcb5161609a458 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Mon, 17 Feb 2025 16:30:02 -0800 Subject: [PATCH 051/149] Changes to startup and added web support --- .metadata | 30 +++ lib/core/config.dart | 6 +- lib/data/models/order.dart | 8 +- .../home/notifiers/home_notifier.dart | 70 ++++--- lib/main.dart | 10 +- lib/services/nostr_service.dart | 4 +- lib/shared/providers/app_init_provider.dart | 13 +- .../providers/mostro_database_provider.dart | 10 +- .../providers/mostro_service_provider.dart | 2 +- .../providers/nostr_service_provider.dart | 16 +- .../providers/order_repository_provider.dart | 7 +- .../widgets/lightning_invoice_scanner.dart | 80 -------- macos/Flutter/GeneratedPluginRegistrant.swift | 4 +- pubspec.lock | 182 ++++++++++++++++-- pubspec.yaml | 11 +- test/widget_test.dart | 30 +++ web/index.html | 4 +- 17 files changed, 305 insertions(+), 182 deletions(-) create mode 100644 .metadata delete mode 100644 lib/shared/widgets/lightning_invoice_scanner.dart create mode 100644 test/widget_test.dart diff --git a/.metadata b/.metadata new file mode 100644 index 00000000..4387f5e3 --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: web + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/lib/core/config.dart b/lib/core/config.dart index 25fe825b..f6c699c4 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -3,9 +3,9 @@ import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ - //'wss://relay.mostro.network', - 'ws://192.168.1.148:7000', - //'ws://127.0.0.1:7000', + 'wss://relay.mostro.network', + 'ws://127.0.0.1:7000', + //'ws://192.168.1.148:7000', //'ws://10.0.2.2:7000', // mobile emulator ]; diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart index 56ed9c55..a71d1904 100644 --- a/lib/data/models/order.dart +++ b/lib/data/models/order.dart @@ -86,14 +86,8 @@ class Order implements Payload { ['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()), + id: json['id'], kind: OrderType.fromString(json['kind'].toString()), status: Status.fromString(json['status']), amount: json['amount'], diff --git a/lib/features/home/notifiers/home_notifier.dart b/lib/features/home/notifiers/home_notifier.dart index a27f12a5..9c64fefd 100644 --- a/lib/features/home/notifiers/home_notifier.dart +++ b/lib/features/home/notifiers/home_notifier.dart @@ -15,27 +15,9 @@ class HomeNotifier extends AsyncNotifier { @override Future build() async { state = const AsyncLoading(); - _repository = ref.watch(orderRepositoryProvider); - _repository!.subscribeToOrders(); - - _subscription = _repository!.eventsStream.listen((orders) { - final orderType = state.value?.orderType ?? OrderType.sell; - final filteredOrders = _filterOrders(orders, orderType); - state = AsyncData( - HomeState( - orderType: orderType, - filteredOrders: filteredOrders, - ), - ); - }, onError: (error) { - state = AsyncError(error, StackTrace.current); - }); - - ref.onDispose(() { - _subscription?.cancel(); - }); + _subscribeToOrderUpdates(); return HomeState( orderType: OrderType.sell, @@ -43,24 +25,49 @@ class HomeNotifier extends AsyncNotifier { ); } - /// Refreshes the data by re-initializing the notifier. - Future refresh() async { + void _subscribeToOrderUpdates() { _subscription?.cancel(); - await build(); + _repository!.subscribeToOrders(); + + _subscription = _repository!.eventsStream.listen( + (orders) { + _updateFilteredOrders(orders); + }, + onError: (error) { + state = AsyncError(error, StackTrace.current); + }, + ); + + ref.onDispose(() => _subscription?.cancel()); } - List _filterOrders(List orders, OrderType type) { - final currentState = state.value; - if (currentState == null) return []; + /// Refreshes the data by fetching new orders without rebuilding the whole notifier. + Future refresh() async { + final orders = _repository?.currentEvents; + if (orders != null) { + _updateFilteredOrders(orders); + } + } + void _updateFilteredOrders(List orders) { + final orderType = state.value?.orderType ?? OrderType.sell; + final filteredOrders = _filterOrders(orders, orderType); + + state = AsyncData( + HomeState( + orderType: orderType, + filteredOrders: filteredOrders, + ), + ); + } + + List _filterOrders(List orders, OrderType type) { final sessionManager = ref.watch(sessionManagerProvider); - final orderIds = sessionManager.sessions.map((s) => s.orderId); + final orderIds = sessionManager.sessions.map((s) => s.orderId).toSet(); return orders .where((order) => !orderIds.contains(order.orderId)) - .where((order) => type == OrderType.buy - ? order.orderType == OrderType.buy - : order.orderType == OrderType.sell) + .where((order) => order.orderType == type) .where((order) => order.status == 'pending') .toList(); } @@ -69,13 +76,14 @@ class HomeNotifier extends AsyncNotifier { final currentState = state.value; if (currentState == null || _repository == null) return; final allOrders = _repository!.currentEvents; - final filteredOrders = _filterOrders(allOrders, type); state = AsyncData( currentState.copyWith( orderType: type, - filteredOrders: filteredOrders, ), ); + + _updateFilteredOrders(allOrders); + } } diff --git a/lib/main.dart b/lib/main.dart index 33f904fe..21021e37 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -5,19 +7,14 @@ import 'package:mostro_mobile/core/app.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/features/settings/settings_notifier.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; -import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; -import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/storage_providers.dart'; import 'package:mostro_mobile/shared/utils/biometrics_helper.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - - final nostrService = NostrService(); - await nostrService.init(); - + final biometricsHelper = BiometricsHelper(); final sharedPreferences = SharedPreferencesAsync(); final secureStorage = const FlutterSecureStorage(); @@ -29,7 +26,6 @@ void main() async { runApp( ProviderScope( overrides: [ - nostrProvider.overrideWithValue(nostrService), settingsProvider.overrideWith((b) => settings), biometricsHelperProvider.overrideWithValue(biometricsHelper), sharedPreferencesProvider.overrideWithValue(sharedPreferences), diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index e9191a75..2392bd43 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -20,7 +20,8 @@ class NostrService { _nostr = Nostr.instance; try { await _nostr.services.relays.init( - relaysUrl: settings?.relays ?? Config.nostrRelays, + ensureToClearRegistriesBeforeStarting: false, + relaysUrl: settings!.relays, connectionTimeout: Config.nostrConnectionTimeout, onRelayListening: (relay, url, channel) { _logger.i('Connected to relay: $relay'); @@ -33,7 +34,6 @@ class NostrService { }, retryOnClose: true, retryOnError: true, - shouldReconnectToRelayOnNotice: true, ); _isInitialized = true; _logger.i('Nostr initialized successfully'); diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index ba8da59e..32538b48 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -4,6 +4,7 @@ import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; @@ -11,6 +12,15 @@ import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; final appInitializerProvider = FutureProvider((ref) async { + + final settings = ref.read(settingsProvider); + final service = ref.read(nostrServiceProvider); + await service.updateSettings(settings); + + ref.listen(settingsProvider, (previous, next) { + service.updateSettings(next); + }); + final keyManager = ref.read(keyManagerProvider); bool hasMaster = await keyManager.hasMasterKey(); if (!hasMaster) { @@ -20,9 +30,6 @@ final appInitializerProvider = FutureProvider((ref) async { final sessionManager = ref.read(sessionManagerProvider); await sessionManager.init(); - final settings = ref.read(settingsProvider); - final service = ref.read(nostrProvider); - service.updateSettings(settings); final mostroRepository = ref.read(mostroRepositoryProvider); await mostroRepository.loadMessages(); diff --git a/lib/shared/providers/mostro_database_provider.dart b/lib/shared/providers/mostro_database_provider.dart index 65d95172..866661d4 100644 --- a/lib/shared/providers/mostro_database_provider.dart +++ b/lib/shared/providers/mostro_database_provider.dart @@ -1,14 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:sembast/sembast_io.dart'; +import 'package:tekartik_app_flutter_sembast/sembast.dart'; Future openMostroDatabase() async { - final dir = await getApplicationDocumentsDirectory(); - await dir.create(recursive: true); - final dbPath = join(dir.path, 'mostro.db'); - final db = await databaseFactoryIo.openDatabase(dbPath); + var factory = getDatabaseFactory(); + final db = await factory.openDatabase('mostro.db'); return db; } diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index 7e5d63c3..bfc9272a 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -7,7 +7,7 @@ import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; final mostroServiceProvider = Provider((ref) { final sessionStorage = ref.watch(sessionManagerProvider); - final nostrService = ref.watch(nostrServicerProvider); + final nostrService = ref.watch(nostrServiceProvider); return MostroService(nostrService, sessionStorage); }); diff --git a/lib/shared/providers/nostr_service_provider.dart b/lib/shared/providers/nostr_service_provider.dart index 3a523a87..94b605a5 100644 --- a/lib/shared/providers/nostr_service_provider.dart +++ b/lib/shared/providers/nostr_service_provider.dart @@ -1,18 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/settings/settings.dart'; -import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; -final nostrProvider = Provider((ref) { - throw UnimplementedError(); -}); - -final nostrServicerProvider = Provider((ref) { - final service = ref.read(nostrProvider); - - ref.listen(settingsProvider, (previous, next) { - service.updateSettings(next); - }); - - return service; +final nostrServiceProvider = Provider((ref) { + return NostrService(); }); diff --git a/lib/shared/providers/order_repository_provider.dart b/lib/shared/providers/order_repository_provider.dart index 0611ee2f..cee4e4ad 100644 --- a/lib/shared/providers/order_repository_provider.dart +++ b/lib/shared/providers/order_repository_provider.dart @@ -4,7 +4,7 @@ import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; final orderRepositoryProvider = Provider((ref) { - final nostrService = ref.read(nostrServicerProvider); + final nostrService = ref.read(nostrServiceProvider); return OpenOrdersRepository(nostrService); }); @@ -15,7 +15,8 @@ final orderEventsProvider = StreamProvider>((ref) { return orderRepository.eventsStream; }); -final eventProvider = FutureProvider.family((ref, orderId) { +final eventProvider = + FutureProvider.family((ref, orderId) { final repository = ref.watch(orderRepositoryProvider); return repository.getOrderById(orderId); -}); \ No newline at end of file +}); diff --git a/lib/shared/widgets/lightning_invoice_scanner.dart b/lib/shared/widgets/lightning_invoice_scanner.dart deleted file mode 100644 index 2d2ad9b1..00000000 --- a/lib/shared/widgets/lightning_invoice_scanner.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; - -class LightningInvoiceScanner extends StatefulWidget { - const LightningInvoiceScanner({super.key}); - - @override - State createState() => - _LightningInvoiceScannerState(); -} - -class _LightningInvoiceScannerState extends State { - final MobileScannerController _controller = MobileScannerController( - detectionSpeed: DetectionSpeed.unrestricted, - ); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // If running on Linux, show an alternate widget - if (defaultTargetPlatform == TargetPlatform.linux) { - return Scaffold( - appBar: AppBar(title: const Text('Scan not supported')), - body: const Center( - child: Text('QR scanning is not supported on Linux.'), - ), - ); - } - - // Define a scan window - final scanWindow = Rect.fromCenter( - center: MediaQuery.of(context).size.center(Offset.zero).translate(0, -100), - width: 300, - height: 200, - ); - - return Scaffold( - appBar: AppBar( - title: const Text('Scan Lightning Invoice'), - ), - body: Stack( - children: [ - // Wrap in a container with explicit height to ensure proper sizing. - SizedBox( - width: double.infinity, - height: MediaQuery.of(context).size.height, - child: MobileScanner( - controller: _controller, - scanWindow: scanWindow, - errorBuilder: (context, error, child) { - return Center( - child: Text( - 'Error: $error', - style: const TextStyle(color: Colors.red), - ), - ); - }, - ), - ), - // Optional overlay to highlight the scan area - Positioned.fromRect( - rect: scanWindow, - child: Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.greenAccent, width: 2), - color: Colors.black.withValues(alpha: .1), - ), - ), - ), - ], - ), - ); - } -} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 46b0a8b7..ea00013e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,14 +7,14 @@ import Foundation import flutter_secure_storage_darwin import local_auth_darwin -import mobile_scanner import path_provider_foundation import shared_preferences_foundation +import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) - MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index cfc0e0ed..f9787680 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" url: "https://pub.dev" source: hosted - version: "2.4.14" + version: "2.4.15" build_runner_core: dependency: transitive description: @@ -265,6 +265,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_flutter_team_lints: + dependency: transitive + description: + name: dart_flutter_team_lints + sha256: "241f050cb4d908caffdcc5d4407e1c4e0b1dd449294ef7b6ceb4a9d8bc77dd96" + url: "https://pub.dev" + source: hosted + version: "3.3.0" dart_nostr: dependency: "direct main" description: @@ -281,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + dev_build: + dependency: transitive + description: + name: dev_build + sha256: "5ed4fe61cead9fd561f13c7a05f6c11002ecc8ec6f5be33f7b9674f49f245434" + url: "https://pub.dev" + source: hosted + version: "1.1.2" elliptic: dependency: "direct main" description: @@ -309,10 +325,10 @@ packages: dependency: transitive description: name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" file: dependency: transitive description: @@ -507,10 +523,10 @@ packages: dependency: "direct main" description: name: heroicons - sha256: "762f907af146105a3956e61e16e0a2cafadd39317e88cc4e4d0ed32846a0452e" + sha256: "4da0ce23d7c25b35390cb6cf485e33b959e811541e9d9cb33a1fceb3d6705f0c" url: "https://pub.dev" source: hosted - version: "0.10.0" + version: "0.11.0" hex: dependency: transitive description: @@ -551,6 +567,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + idb_shim: + dependency: transitive + description: + name: idb_shim + sha256: "2fe625a67b693ae9b7d02bc8f3ff7c5176ab6be5c9e4ac2cd11924fbb54b5a6c" + url: "https://pub.dev" + source: hosted + version: "2.6.2" image: dependency: transitive description: @@ -716,14 +740,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - mobile_scanner: - dependency: "direct main" - description: - name: mobile_scanner - sha256: "91d28b825784e15572fdc39165c5733099ce0e69c6f6f0964ebdbf98a62130fd" - url: "https://pub.dev" - source: hosted - version: "6.0.6" mockito: dependency: "direct dev" description: @@ -774,7 +790,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: "direct main" + dependency: transitive description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -877,6 +893,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.3" + process_run: + dependency: transitive + description: + name: process_run + sha256: "01a48e04fbb489d1e4610ed361f6b3f21aea0966dfa0ff9227e9c4095cd6d3fb" + url: "https://pub.dev" + source: hosted + version: "1.2.3" pub_semver: dependency: transitive description: @@ -925,6 +949,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.8.4" + sembast_sqflite: + dependency: transitive + description: + name: sembast_sqflite + sha256: "38ffa967af36d6867d087ec82c37b70b55707b2cd533157e8a32d15aa66e40d2" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + sembast_web: + dependency: "direct main" + description: + name: sembast_web + sha256: a3ae64d8b1e87af98fbfcb590496e88dd6374ae362bd960ffa692130ee74b9c5 + url: "https://pub.dev" + source: hosted + version: "2.4.1" shared_preferences: dependency: "direct main" description: @@ -1009,10 +1049,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -1058,6 +1098,70 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" + url: "https://pub.dev" + source: hosted + version: "2.5.5" + sqflite_common_ffi: + dependency: transitive + description: + name: sqflite_common_ffi + sha256: "1f3ef3888d3bfbb47785cc1dda0dc7dd7ebd8c1955d32a9e8e9dae1e38d1c4c1" + url: "https://pub.dev" + source: hosted + version: "2.3.5" + sqflite_common_ffi_web: + dependency: transitive + description: + name: sqflite_common_ffi_web + sha256: "983cf7b33b16e6bc086c8e09f6a1fae69d34cdb167d7acaf64cbd3515942d4e6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "32b632dda27d664f85520093ed6f735ae5c49b5b75345afb8b19411bc59bb53d" + url: "https://pub.dev" + source: hosted + version: "2.7.4" stack_trace: dependency: transitive description: @@ -1114,6 +1218,42 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" + tekartik_app_flutter_sembast: + dependency: "direct main" + description: + path: app_sembast + ref: dart3a + resolved-ref: "87c10a1981e0cc63b9b5867a80f1c6508d20c4fa" + url: "https://github.com/tekartik/app_flutter_utils.dart" + source: git + version: "0.5.1" + tekartik_app_flutter_sqflite: + dependency: transitive + description: + path: app_sqflite + ref: dart3a + resolved-ref: "87c10a1981e0cc63b9b5867a80f1c6508d20c4fa" + url: "https://github.com/tekartik/app_flutter_utils.dart" + source: git + version: "0.6.1" + tekartik_lints: + dependency: transitive + description: + path: "packages/lints" + ref: dart3a + resolved-ref: "6f9dded79246357567633739554ae58bde1bc9a3" + url: "https://github.com/tekartik/common.dart" + source: git + version: "0.4.2" + tekartik_lints_flutter: + dependency: transitive + description: + path: "packages/lints_flutter" + ref: dart3a + resolved-ref: "4d58d3cc559c5c92854fce73fc176c5951e78338" + url: "https://github.com/tekartik/common_flutter.dart" + source: git + version: "0.2.3" term_glyph: dependency: transitive description: @@ -1210,6 +1350,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + very_good_analysis: + dependency: transitive + description: + name: very_good_analysis + sha256: "62d2b86d183fb81b2edc22913d9f155d26eb5cf3855173adb1f59fac85035c63" + url: "https://pub.dev" + source: hosted + version: "7.0.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a0614883..d3bad73f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: http: ^1.2.2 dart_nostr: ^9.0.0 qr_flutter: ^4.0.0 - heroicons: ^0.10.0 + heroicons: ^0.11.0 crypto: ^3.0.5 convert: ^3.1.1 shared_preferences: ^2.3.3 @@ -59,8 +59,7 @@ dependencies: bip32: ^2.0.0 path: ^1.9.0 sembast: ^3.8.2 - path_provider: ^2.1.5 - mobile_scanner: ^6.0.6 + sembast_web: ^2.4.1 flutter_localizations: sdk: flutter @@ -70,6 +69,12 @@ dependencies: url: https://github.com/chebizarro/dart-nip44.git ref: master + tekartik_app_flutter_sembast: + git: + url: https://github.com/tekartik/app_flutter_utils.dart + ref: dart3a + path: app_sembast + version: '>=0.1.0' dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 00000000..61b3ed79 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mostro_mobile/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/web/index.html b/web/index.html index 2c5728bb..7c968e75 100644 --- a/web/index.html +++ b/web/index.html @@ -21,7 +21,7 @@ - + @@ -29,7 +29,7 @@ - Mostro P2P + mostro_mobile From 83ff94b06a53d2c946c10bc6c6f58e773b6a2873 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Tue, 18 Feb 2025 09:15:47 -0800 Subject: [PATCH 052/149] Added public test nostr relay and daemon as default --- lib/core/config.dart | 5 +++-- lib/main.dart | 2 -- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/core/config.dart b/lib/core/config.dart index f6c699c4..835e559e 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -4,14 +4,15 @@ class Config { // Configuración de Nostr static const List nostrRelays = [ 'wss://relay.mostro.network', - 'ws://127.0.0.1:7000', + //'ws://127.0.0.1:7000', //'ws://192.168.1.148:7000', //'ws://10.0.2.2:7000', // mobile emulator ]; // hexkey de Mostro static const String mostroPubKey = - '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + // '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; static const String dBName = 'mostro.db'; static const String dBPassword = 'mostro'; diff --git a/lib/main.dart b/lib/main.dart index 21021e37..8ee75534 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; From c8bc4f19aedbbc9ea6d9d94f1a344e192f585305 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 19 Feb 2025 19:27:31 -0800 Subject: [PATCH 053/149] set permissions for secure storage --- .metadata | 2 +- android/app/src/main/AndroidManifest.xml | 75 ++++++++++--------- lib/core/config.dart | 8 +- .../repositories/open_orders_repository.dart | 4 +- lib/data/repositories/session_manager.dart | 2 +- lib/features/mostro/mostro_instance.dart | 16 ++-- lib/services/mostro_service.dart | 6 +- macos/Runner/DebugProfile.entitlements | 20 ++--- macos/Runner/Release.entitlements | 12 +-- 9 files changed, 79 insertions(+), 66 deletions(-) diff --git a/.metadata b/.metadata index 4387f5e3..b4fb3853 100644 --- a/.metadata +++ b/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 - - platform: web + - platform: ios create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9debb7ea..b9ec7241 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,45 +1,48 @@ - - - - - - - - - - - - - - - - - - - - + + + + + + + +
\ No newline at end of file diff --git a/lib/core/config.dart b/lib/core/config.dart index 835e559e..d16ac1eb 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -3,16 +3,16 @@ import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ - 'wss://relay.mostro.network', + //'wss://relay.mostro.network', //'ws://127.0.0.1:7000', - //'ws://192.168.1.148:7000', + 'ws://192.168.1.148:7000', //'ws://10.0.2.2:7000', // mobile emulator ]; // hexkey de Mostro static const String mostroPubKey = - '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; - // '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; + '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; + // '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; static const String dBName = 'mostro.db'; static const String dBPassword = 'mostro'; diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index cc73eacd..3c7ffdf7 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:logger/logger.dart'; +import 'package:mostro_mobile/core/config.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'; @@ -38,7 +39,8 @@ class OpenOrdersRepository implements OrderRepository { if (event.type == 'order') { _events[event.orderId!] = event; _eventStreamController.add(_events.values.toList()); - } else if (event.type == 'info') { + } else if (event.type == 'info' && event.pubkey == Config.mostroPubKey) { + _logger.i('Mostro instance info loaded: $event'); _mostroInstance = event; } }, onError: (error) { diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 398b77d1..2c847d52 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -43,7 +43,7 @@ class SessionManager { masterKey: masterKey, keyIndex: keyIndex, tradeKey: tradeKey, - fullPrivacy: true, + fullPrivacy: false, orderId: orderId, ); diff --git a/lib/features/mostro/mostro_instance.dart b/lib/features/mostro/mostro_instance.dart index e57ed7a8..48118048 100644 --- a/lib/features/mostro/mostro_instance.dart +++ b/lib/features/mostro/mostro_instance.dart @@ -48,18 +48,18 @@ class MostroInstance { } extension MostroInstanceExtensions on NostrEvent { - String? _getTagValue(String key) { + String _getTagValue(String key) { final tag = tags?.firstWhere((t) => t[0] == key, orElse: () => []); - return (tag != null && tag.length > 1) ? tag[1] : null; + return (tag != null && tag.length > 1) ? tag[1] : 'Tag: $key not found'; } String get pubKey => _getTagValue('d')!; - String get mostroVersion => _getTagValue('mostro_version')!; - String get commitHash => _getTagValue('mostro_commit_hash')!; - int get maxOrderAmount => int.parse(_getTagValue('max_order_amount')!); - int get minOrderAmount => int.parse(_getTagValue('min_order_amount')!); - int get expirationHours => int.parse(_getTagValue('expiration_hours')!); - int get expirationSeconds => int.parse(_getTagValue('expiration_seconds')!); + String get mostroVersion => _getTagValue('mostro_version'); + String get commitHash => _getTagValue('mostro_commit_hash'); + int get maxOrderAmount => int.parse(_getTagValue('max_order_amount')); + int get minOrderAmount => int.parse(_getTagValue('min_order_amount')); + int get expirationHours => int.parse(_getTagValue('expiration_hours')); + int get expirationSeconds => int.parse(_getTagValue('expiration_seconds')); double get fee => double.parse(_getTagValue('fee')!); int get pow => int.parse(_getTagValue('pow')!); int get holdInvoiceExpirationWindow => diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index e2bf99b1..7c38cfe5 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -22,6 +22,8 @@ class MostroService { Stream subscribe(Session session) { final filter = NostrFilter(p: [session.tradeKey.public]); return _nostrService.subscribeToEvents(filter).asyncMap((event) async { + _logger.i('Event received from Mostro: $event'); + final decryptedEvent = await _nostrService.decryptNIP59Event( event, session.tradeKey.private); @@ -116,7 +118,7 @@ class MostroService { if (!session.fullPrivacy) { order.tradeIndex = session.keyIndex; final message = {'order': order.toJson()}; - final serializedEvent = jsonEncode(message['order']); + final serializedEvent = jsonEncode(message); final bytes = utf8.encode(serializedEvent); final digest = sha256.convert(bytes); final hash = hex.encode(digest.bytes); @@ -128,8 +130,10 @@ class MostroService { null ]); } + _logger.i('Publishing order: $content'); final event = await createNIP59Event(content, Config.mostroPubKey, session); await _nostrService.publishEvent(event); + _logger.i('Publishing order: $event'); return session; } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a30..b1b6441f 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -1,12 +1,14 @@ - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - - + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + keychain-access-groups + + + \ No newline at end of file diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a4..37eff5e6 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -1,8 +1,10 @@ - - com.apple.security.app-sandbox - - - + + com.apple.security.app-sandbox + + keychain-access-groups + + + \ No newline at end of file From f0f1ef95b03758ff31a2033be8724906231bee51 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 20 Feb 2025 00:07:09 -0800 Subject: [PATCH 054/149] UX clean up --- assets/images/logo.png | Bin 0 -> 16769 bytes lib/core/config.dart | 9 +- lib/data/models/rating.dart | 30 ++-- lib/data/repositories/session_manager.dart | 2 +- lib/features/mostro/mostro_instance.dart | 12 +- .../order/screens/add_order_screen.dart | 10 +- .../order/widgets/buy_form_widget.dart | 119 ---------------- .../order/widgets/sell_form_widget.dart | 82 ----------- .../relays/widgets/relay_selector.dart | 125 ++++++++++++++++ lib/features/settings/settings_screen.dart | 57 +++++--- lib/services/nostr_service.dart | 1 - lib/services/yadio_exchange_service.dart | 3 +- lib/shared/widgets/currency_combo_box.dart | 133 ++++++++++++++++++ lib/shared/widgets/currency_dropdown.dart | 79 ----------- lib/shared/widgets/mostro_app_drawer.dart | 18 +-- lib/shared/widgets/order_filter.dart | 1 - lib/shared/widgets/privacy_switch_widget.dart | 2 +- 17 files changed, 335 insertions(+), 348 deletions(-) create mode 100644 assets/images/logo.png delete mode 100644 lib/features/order/widgets/buy_form_widget.dart delete mode 100644 lib/features/order/widgets/sell_form_widget.dart create mode 100644 lib/features/relays/widgets/relay_selector.dart create mode 100644 lib/shared/widgets/currency_combo_box.dart delete mode 100644 lib/shared/widgets/currency_dropdown.dart diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d2e232d50c989491fcd45b1a7e82a8b1b751bbf2 GIT binary patch literal 16769 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 nostrRelays = [ - //'wss://relay.mostro.network', + 'wss://relay.mostro.network', //'ws://127.0.0.1:7000', - 'ws://192.168.1.148:7000', + //'ws://192.168.1.148:7000', //'ws://10.0.2.2:7000', // mobile emulator ]; // hexkey de Mostro static const String mostroPubKey = - '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; - // '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; +// '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; + static const String dBName = 'mostro.db'; static const String dBPassword = 'mostro'; diff --git a/lib/data/models/rating.dart b/lib/data/models/rating.dart index 1d053377..1698de6d 100644 --- a/lib/data/models/rating.dart +++ b/lib/data/models/rating.dart @@ -25,16 +25,26 @@ class Rating { } 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'), - ); + final json = jsonDecode(data); + if (json is 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'), + ); + } else { + return Rating( + totalReviews: 0, + totalRating: (json as int).toDouble(), + lastRating: 0, + maxRate: 0, + minRate: 0, + ); + } } catch (e) { - throw FormatException('Failed to parse rating data: $e'); + return Rating.empty(); } } @@ -61,4 +71,4 @@ class Rating { minRate: 0, ); } -} \ No newline at end of file +} diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 2c847d52..398b77d1 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -43,7 +43,7 @@ class SessionManager { masterKey: masterKey, keyIndex: keyIndex, tradeKey: tradeKey, - fullPrivacy: false, + fullPrivacy: true, orderId: orderId, ); diff --git a/lib/features/mostro/mostro_instance.dart b/lib/features/mostro/mostro_instance.dart index 48118048..98f096ca 100644 --- a/lib/features/mostro/mostro_instance.dart +++ b/lib/features/mostro/mostro_instance.dart @@ -53,19 +53,19 @@ extension MostroInstanceExtensions on NostrEvent { return (tag != null && tag.length > 1) ? tag[1] : 'Tag: $key not found'; } - String get pubKey => _getTagValue('d')!; + String get pubKey => _getTagValue('d'); String get mostroVersion => _getTagValue('mostro_version'); String get commitHash => _getTagValue('mostro_commit_hash'); int get maxOrderAmount => int.parse(_getTagValue('max_order_amount')); int get minOrderAmount => int.parse(_getTagValue('min_order_amount')); int get expirationHours => int.parse(_getTagValue('expiration_hours')); int get expirationSeconds => int.parse(_getTagValue('expiration_seconds')); - double get fee => double.parse(_getTagValue('fee')!); - int get pow => int.parse(_getTagValue('pow')!); + double get fee => double.parse(_getTagValue('fee')); + int get pow => int.parse(_getTagValue('pow')); int get holdInvoiceExpirationWindow => - int.parse(_getTagValue('hold_invoice_expiration_window')!); + int.parse(_getTagValue('hold_invoice_expiration_window')); int get holdInvoiceCltvDelta => - int.parse(_getTagValue('hold_invoice_cltv_delta')!); + int.parse(_getTagValue('hold_invoice_cltv_delta')); int get invoiceExpirationWindow => - int.parse(_getTagValue('invoice_expiration_window')!); + int.parse(_getTagValue('invoice_expiration_window')); } diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 17ed71aa..a34b1a8c 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -9,7 +9,7 @@ import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/order/widgets/fixed_switch_widget.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; -import 'package:mostro_mobile/shared/widgets/currency_dropdown.dart'; +import 'package:mostro_mobile/shared/widgets/currency_combo_box.dart'; import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:uuid/uuid.dart'; @@ -28,7 +28,7 @@ class _AddOrderScreenState extends ConsumerState { final _paymentMethodController = TextEditingController(); final _lightningInvoiceController = TextEditingController(); - bool _marketRate = false; // false => Fixed, true => Market + bool _marketRate = true; // false => Fixed, true => Market double _premiumValue = 0.0; // slider for -10..10 bool _isEnabled = false; // controls enabled or not @@ -156,7 +156,7 @@ class _AddOrderScreenState extends ConsumerState { const SizedBox(height: 16), // 1) Currency dropdown always enabled - CurrencyDropdown( + CurrencyComboBox( key: const Key("fiatCodeDropdown"), label: 'Fiat code', onSelected: (String fiatCode) { @@ -253,7 +253,7 @@ class _AddOrderScreenState extends ConsumerState { const SizedBox(height: 16), // 1) Currency dropdown always enabled - CurrencyDropdown( + CurrencyComboBox( key: const Key('fiatCodeDropdown'), label: 'Fiat code', onSelected: (String fiatCode) { @@ -328,7 +328,7 @@ class _AddOrderScreenState extends ConsumerState { _buildDisabledWrapper( enabled: _isEnabled, child: _buildTextField( - 'Lightning Invoice or Lightning Address', + 'Lightning Address or Lightning Invoice without an amount', const Key('lightningInvoiceField'), _lightningInvoiceController, nullable: true, diff --git a/lib/features/order/widgets/buy_form_widget.dart b/lib/features/order/widgets/buy_form_widget.dart deleted file mode 100644 index d12f9baa..00000000 --- a/lib/features/order/widgets/buy_form_widget.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:bitcoin_icons/bitcoin_icons.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/shared/widgets/currency_dropdown.dart'; -import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; - -class BuyFormWidget extends HookConsumerWidget { - const BuyFormWidget({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final formKey = useMemoized(() => GlobalKey()); - - final fiatAmountController = useTextEditingController(); - final satsAmountController = useTextEditingController(); - final paymentMethodController = useTextEditingController(); - final lightningInvoiceController = useTextEditingController(); - - 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: AppTheme.grey2)), - const SizedBox(height: 16), - CurrencyDropdown(label: 'Fiat code'), - const SizedBox(height: 16), - CurrencyTextField( - controller: fiatAmountController, label: 'Fiat amount'), - const SizedBox(height: 16), - _buildFixedToggle(), - const SizedBox(height: 16), - _buildTextField('Sats amount', satsAmountController, - 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, ref, formKey), - ], - ), - ); - } - - Widget _buildTextField(String label, TextEditingController controller, - {IconData? suffix}) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: AppTheme.dark1, - borderRadius: BorderRadius.circular(8), - ), - child: TextFormField( - controller: controller, - style: const TextStyle(color: AppTheme.cream1), - decoration: InputDecoration( - border: InputBorder.none, - labelText: label, - 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; - }, - ), - ); - } - - - Widget _buildFixedToggle() { - return Row( - children: [ - const Text('Fixed', style: TextStyle(color: AppTheme.cream1)), - const SizedBox(width: 8), - Switch( - value: false, - onChanged: (value) {}, - ), - ], - ); - } - - Widget _buildActionButtons( - BuildContext context, WidgetRef ref, GlobalKey formKey) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => context.go('/'), - child: const Text('CANCEL', style: TextStyle(color: AppTheme.red2)), - ), - const SizedBox(width: 16), - ElevatedButton( - onPressed: () { - if (formKey.currentState?.validate() ?? false) { - //_submitOrder(context, ref, OrderType.buy); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('SUBMIT'), - ), - ], - ); - } - - -} diff --git a/lib/features/order/widgets/sell_form_widget.dart b/lib/features/order/widgets/sell_form_widget.dart deleted file mode 100644 index 6465b1a2..00000000 --- a/lib/features/order/widgets/sell_form_widget.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:bitcoin_icons/bitcoin_icons.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/shared/widgets/currency_dropdown.dart'; -import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; - -class SellFormWidget extends HookConsumerWidget { - const SellFormWidget({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - - final fiatAmountController = useTextEditingController(); - final satsAmountController = useTextEditingController(); - final paymentMethodController = useTextEditingController(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Make sure your order is below 20K sats', - style: TextStyle(color: AppTheme.grey2)), - const SizedBox(height: 16), - CurrencyDropdown(label: 'Fiat code'), - const SizedBox(height: 16), - CurrencyTextField( - controller: fiatAmountController, label: 'Fiat amount'), - const SizedBox(height: 16), - _buildFixedToggle(), - const SizedBox(height: 16), - _buildTextField('Sats amount', satsAmountController, - suffix: Icon(BitcoinIcons.satoshi_v1_outline).icon), - const SizedBox(height: 16), - _buildTextField('Payment method', paymentMethodController), - const SizedBox(height: 32), - ], - ); - } - - Widget _buildTextField(String label, TextEditingController controller, - {IconData? suffix}) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: AppTheme.dark1, - borderRadius: BorderRadius.circular(8), - ), - child: TextFormField( - controller: controller, - style: const TextStyle(color: AppTheme.cream1), - decoration: InputDecoration( - border: InputBorder.none, - labelText: label, - 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; - }, - ), - ); - } - - Widget _buildFixedToggle() { - return Row( - children: [ - const Text('Fixed', style: TextStyle(color: AppTheme.cream1)), - const SizedBox(width: 8), - Switch( - value: false, - onChanged: (value) {}, - ), - ], - ); - } - -} diff --git a/lib/features/relays/widgets/relay_selector.dart b/lib/features/relays/widgets/relay_selector.dart new file mode 100644 index 00000000..323f6625 --- /dev/null +++ b/lib/features/relays/widgets/relay_selector.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/relays/relay.dart'; +import 'package:mostro_mobile/features/relays/relays_provider.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; + +class RelaySelector extends ConsumerWidget { + const RelaySelector({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final relays = ref.watch(relaysProvider); + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: settings.relays.length, + itemBuilder: (context, index) { + final relay = relays[index]; + return Card( + color: AppTheme.dark2, + margin: const EdgeInsets.symmetric(vertical: 8), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + title: Text( + relay.url, + style: const TextStyle(color: Colors.white), + ), + leading: Icon( + Icons.circle, + color: relay.isHealthy ? Colors.green : Colors.red, + size: 16, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, color: Colors.white), + onPressed: () { + _showEditDialog(context, relay, ref); + }, + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.white), + onPressed: () { + ref.read(relaysProvider.notifier).removeRelay(relay.url); + }, + ), + ], + ), + ), + ); + }, + ); + } + + void _showAddDialog(BuildContext context, WidgetRef ref) { + final controller = TextEditingController(); + showDialog( + useRootNavigator: true, + context: context, + builder: (BuildContext dialogContext) => AlertDialog( + title: const Text('Add Relay'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Relay URL'), + ), + actions: [ + TextButton( + onPressed: () {}, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final url = controller.text.trim(); + if (url.isNotEmpty) { + final newRelay = Relay(url: url, isHealthy: true); + ref.read(relaysProvider.notifier).addRelay(newRelay); + } + + }, + child: const Text('Add'), + ), + ], + ), + ); + } + + void _showEditDialog(BuildContext context, Relay relay, WidgetRef ref) { + final controller = TextEditingController(text: relay.url); + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Edit Relay'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Relay URL'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final newUrl = controller.text.trim(); + if (newUrl.isNotEmpty && newUrl != relay.url) { + final updatedRelay = relay.copyWith(url: newUrl); + ref + .read(relaysProvider.notifier) + .updateRelay(relay, updatedRelay); + } + Navigator.pop(dialogContext); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 7b3aeddb..4b93198d 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/relays/widgets/relay_selector.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/widgets/privacy_switch_widget.dart'; @@ -21,35 +22,47 @@ class SettingsScreen extends ConsumerWidget { icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), onPressed: () => context.pop(), ), - title: Text( - 'APP SETTINGS', + title: const Text( + 'SETTINGS', style: TextStyle( color: AppTheme.cream1, ), ), ), - backgroundColor: AppTheme.dark1, - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - const SizedBox(height: 16), - const Text( - 'Privacy', - style: TextStyle(color: AppTheme.cream1, fontSize: 18), + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + Card( + child: ListTile( + title: Text('General Settings'), ), - const SizedBox(height: 8), - PrivacySwitch( - initialValue: settings.fullPrivacyMode, - onChanged: (newValue) { - ref - .watch(settingsProvider.notifier) - .updatePrivacyModeSetting(newValue); - }, + ), + const SizedBox(height: 16), + PrivacySwitch( + initialValue: settings.fullPrivacyMode, + onChanged: (newValue) { + ref + .read(settingsProvider.notifier) + .updatePrivacyModeSetting(newValue); + }, + ), + const SizedBox(height: 16), + Card( + child: ListTile( + title: Text('Relays'), ), - const SizedBox(height: 16), - ], - ), + ), + SizedBox( + height: 200, + child: RelaySelector(), + ), + const SizedBox(height: 16), + Card( + child: ListTile( + title: Text('Mostro'), + ), + ), + ], ), ); } diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 2392bd43..1bf6be52 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -20,7 +20,6 @@ class NostrService { _nostr = Nostr.instance; try { await _nostr.services.relays.init( - ensureToClearRegistriesBeforeStarting: false, relaysUrl: settings!.relays, connectionTimeout: Config.nostrConnectionTimeout, onRelayListening: (relay, url, channel) { diff --git a/lib/services/yadio_exchange_service.dart b/lib/services/yadio_exchange_service.dart index a0f96cb8..243ffa0e 100644 --- a/lib/services/yadio_exchange_service.dart +++ b/lib/services/yadio_exchange_service.dart @@ -36,7 +36,8 @@ class YadioExchangeService extends ExchangeService { try { final data = await getRequest(endpoint); return Map.fromEntries( - data.entries.map((entry) { + data.entries.where((entry) => entry.key != 'BTC') + .map((entry) { return MapEntry(entry.key, entry.value?.toString() ?? ''); }), ); diff --git a/lib/shared/widgets/currency_combo_box.dart b/lib/shared/widgets/currency_combo_box.dart new file mode 100644 index 00000000..50370b51 --- /dev/null +++ b/lib/shared/widgets/currency_combo_box.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; + +class CurrencyComboBox extends ConsumerWidget { + final String label; + final ValueChanged? onSelected; + + const CurrencyComboBox({ + super.key, + required this.label, + this.onSelected, + }); + + @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: [ + const Text('Failed to load currencies'), + TextButton( + onPressed: () => ref.refresh(currencyCodesProvider), + child: const Text('Retry'), + ), + ], + ), + data: (currencyCodes) { + // Create a list of string labels like "USD - United States Dollar" + final entries = currencyCodes.entries.map((e) => '${e.key} - ${e.value}').toList(); + + return Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty) { + // If user hasn’t typed anything, show all entries + return entries; + } + final query = textEditingValue.text.toLowerCase(); + return entries.where( + (item) => item.toLowerCase().contains(query), + ); + }, + onSelected: (String selection) { + // Extract the ISO code (the part before " - ") + final code = selection.split(' - ').first; + // Update Riverpod state + ref.read(selectedFiatCodeProvider.notifier).state = code; + // Notify parent via callback if provided + onSelected?.call(code); + }, + fieldViewBuilder: + (context, textEditingController, focusNode, onFieldSubmitted) { + // Initialize the text field with the selected code + // so it shows up when the user opens the screen + if (selectedFiatCode != null) { + final existingLabel = currencyCodes[selectedFiatCode]; + if (existingLabel != null) { + textEditingController.text = '$selectedFiatCode - $existingLabel'; + } + } + + return TextFormField( + controller: textEditingController, + focusNode: focusNode, + decoration: InputDecoration( + border: InputBorder.none, + labelText: label, + labelStyle: const TextStyle(color: AppTheme.grey2), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + style: const TextStyle(color: AppTheme.cream1), + onFieldSubmitted: (value) => onFieldSubmitted(), + ); + }, + optionsViewBuilder: (context, onSelected, options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + color: AppTheme.dark1, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(8), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: options.length, + itemBuilder: (context, index) { + final option = options.elementAt(index); + return InkWell( + onTap: () => onSelected(option), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Text( + option, + style: const TextStyle(color: AppTheme.cream1), + ), + ), + ); + }, + ), + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/shared/widgets/currency_dropdown.dart b/lib/shared/widgets/currency_dropdown.dart deleted file mode 100644 index e7ac1fdb..00000000 --- a/lib/shared/widgets/currency_dropdown.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; - -class CurrencyDropdown extends ConsumerWidget { - final String label; - final ValueChanged? onSelected; - - const CurrencyDropdown({ - super.key, - required this.label, - this.onSelected, - }); - - @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: [ - const 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]}', - key: Key('currency_$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: const TextStyle(color: AppTheme.cream1), - items: items, - value: selectedFiatCode, - onChanged: (value) { - if (value != null) { - // Update Riverpod state - ref.read(selectedFiatCodeProvider.notifier).state = value; - // Notify parent via callback if provided - onSelected?.call(value); - } - }, - ); - }, - ), - ); - } -} diff --git a/lib/shared/widgets/mostro_app_drawer.dart b/lib/shared/widgets/mostro_app_drawer.dart index 56c73cf7..1f57c499 100644 --- a/lib/shared/widgets/mostro_app_drawer.dart +++ b/lib/shared/widgets/mostro_app_drawer.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:mostro_mobile/core/app_theme.dart'; class MostroAppDrawer extends StatelessWidget { @@ -16,22 +15,9 @@ class MostroAppDrawer extends StatelessWidget { decoration: BoxDecoration( color: AppTheme.dark1, image: const DecorationImage( - image: AssetImage('assets/images/mostro-icons.png'), + image: AssetImage('assets/images/logo.png'), fit: BoxFit.scaleDown)), child: Stack( - children: [ - Positioned( - bottom: 8.0, - left: 4.0, - child: Text( - 'Mostro', - style: TextStyle( - color: AppTheme.cream1, - fontFamily: GoogleFonts.robotoCondensed().fontFamily, - ), - ), - ) - ], ), ), ListTile( @@ -47,7 +33,7 @@ class MostroAppDrawer extends StatelessWidget { }, ), ListTile( - title: const Text('App Settings'), + title: const Text('Settings'), onTap: () { context.push('/settings'); }, diff --git a/lib/shared/widgets/order_filter.dart b/lib/shared/widgets/order_filter.dart index a30e6d38..c15f56d1 100644 --- a/lib/shared/widgets/order_filter.dart +++ b/lib/shared/widgets/order_filter.dart @@ -52,7 +52,6 @@ class OrderFilter extends StatelessWidget { SizedBox(height: 20), buildDropdownSection(context, 'Fiat currencies', []), buildDropdownSection(context, 'Payment methods', []), - buildDropdownSection(context, 'Countries', []), buildDropdownSection(context, 'Rating', []), ], ), diff --git a/lib/shared/widgets/privacy_switch_widget.dart b/lib/shared/widgets/privacy_switch_widget.dart index dd3c7262..2bf92cd6 100644 --- a/lib/shared/widgets/privacy_switch_widget.dart +++ b/lib/shared/widgets/privacy_switch_widget.dart @@ -51,7 +51,7 @@ class _PrivacySwitchState extends State { const SizedBox(width: 8), // A text label that changes based on the switch value. Text( - _isFullPrivacy ? 'Full Privacy' : 'Normal', + _isFullPrivacy ? 'Full Privacy' : 'Normal Privacy', style: const TextStyle(color: Colors.white), ), ], From 758502676d5ecada1135571ef8a8126bae84560f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Thu, 20 Feb 2025 15:48:50 -0300 Subject: [PATCH 055/149] Add mostro pubkey filter getting events --- lib/data/repositories/open_orders_repository.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 3c7ffdf7..15a94085 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -32,6 +32,7 @@ class OpenOrdersRepository implements OrderRepository { DateTime.now().subtract(Duration(hours: orderFilterDurationHours)); var filter = NostrFilter( kinds: const [orderEventKind], + authors: [Config.mostroPubKey], since: filterTime, ); From 91dfaf481c53d49d636532773656b74dd598828c Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 20 Feb 2025 16:46:47 -0800 Subject: [PATCH 056/149] fixed privacy mode --- lib/core/app.dart | 5 +- lib/core/config.dart | 6 +- lib/data/models/nostr_event.dart | 7 ++ lib/data/repositories/session_manager.dart | 12 +- .../home/notifiers/home_notifier.dart | 5 +- .../mostro/mostro_settings_widget.dart | 17 +++ lib/features/relays/relays_screen.dart | 116 +----------------- .../relays/widgets/relay_selector.dart | 4 +- lib/features/settings/settings.dart | 13 +- lib/features/settings/settings_notifier.dart | 6 + lib/features/settings/settings_screen.dart | 93 +++++++++----- lib/services/mostro_service.dart | 2 +- lib/services/nostr_service.dart | 13 +- lib/shared/providers/app_init_provider.dart | 9 +- pubspec.lock | 20 ++- pubspec.yaml | 1 + 16 files changed, 157 insertions(+), 172 deletions(-) create mode 100644 lib/features/mostro/mostro_settings_widget.dart diff --git a/lib/core/app.dart b/lib/core/app.dart index 41621e1a..e87687d7 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -35,6 +35,7 @@ class MostroApp extends ConsumerWidget { return MaterialApp.router( title: 'Mostro', theme: AppTheme.theme, + darkTheme: AppTheme.theme, routerConfig: goRouter, localizationsDelegates: const [ S.delegate, @@ -45,7 +46,9 @@ class MostroApp extends ConsumerWidget { supportedLocales: S.delegate.supportedLocales, ); }, - loading: () => const MaterialApp( + loading: () => MaterialApp( + theme: AppTheme.theme, + darkTheme: AppTheme.theme, home: Scaffold( backgroundColor: AppTheme.dark1, body: Center(child: CircularProgressIndicator()), diff --git a/lib/core/config.dart b/lib/core/config.dart index e7b24464..b89c227c 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -3,7 +3,8 @@ import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ - 'wss://relay.mostro.network', + //'wss://relay.mostro.network', + //'wss://nostr.bilthon.dev', //'ws://127.0.0.1:7000', //'ws://192.168.1.148:7000', //'ws://10.0.2.2:7000', // mobile emulator @@ -12,8 +13,7 @@ class Config { // hexkey de Mostro static const String mostroPubKey = '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; -// '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; - + // '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; static const String dBName = 'mostro.db'; static const String dBPassword = 'mostro'; diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index 4d2655ce..58c4f9ac 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -27,6 +27,7 @@ extension NostrEventExtensions on NostrEvent { String? get geohash => _getTagValue('g'); String? get bond => _getTagValue('bond'); String? get expiration => _timeAgo(_getTagValue('expiration')); + DateTime get expirationDate => _getTimeStamp(_getTagValue('expiration')!); String? get platform => _getTagValue('y'); String get type => _getTagValue('z')!; @@ -42,6 +43,12 @@ extension NostrEventExtensions on NostrEvent { : RangeAmount.empty(); } + DateTime _getTimeStamp(String timestamp) { + final ts = int.parse(timestamp); + return DateTime.fromMillisecondsSinceEpoch(ts * 1000) + .subtract(Duration(hours: 36)); + } + String _timeAgo(String? ts) { if (ts == null) return "invalid date"; final timestamp = int.tryParse(ts); diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 398b77d1..85e791db 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -5,9 +5,11 @@ import 'package:mostro_mobile/data/repositories/session_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager.dart'; class SessionManager { + final Logger _logger = Logger(); + final KeyManager _keyManager; final SessionStorage _sessionStorage; - final Logger _logger = Logger(); + bool fullPrivacyMode = true; // In-memory session cache final Map _sessions = {}; @@ -17,6 +19,9 @@ class SessionManager { static const cleanupIntervalMinutes = 30; static const maxBatchSize = 100; + /// Returns all in-memory sessions. + List get sessions => _sessions.values.toList(); + SessionManager( this._keyManager, this._sessionStorage, @@ -43,7 +48,7 @@ class SessionManager { masterKey: masterKey, keyIndex: keyIndex, tradeKey: tradeKey, - fullPrivacy: true, + fullPrivacy: fullPrivacyMode, orderId: orderId, ); @@ -119,7 +124,4 @@ class SessionManager { void dispose() { _cleanupTimer?.cancel(); } - - /// Returns all in-memory sessions. - List get sessions => _sessions.values.toList(); } diff --git a/lib/features/home/notifiers/home_notifier.dart b/lib/features/home/notifiers/home_notifier.dart index 9c64fefd..f92db9dd 100644 --- a/lib/features/home/notifiers/home_notifier.dart +++ b/lib/features/home/notifiers/home_notifier.dart @@ -65,7 +65,9 @@ class HomeNotifier extends AsyncNotifier { final sessionManager = ref.watch(sessionManagerProvider); final orderIds = sessionManager.sessions.map((s) => s.orderId).toSet(); - return orders + orders.sort((o1, o2) => o1.expirationDate.compareTo(o2.expirationDate)); + + return orders.reversed .where((order) => !orderIds.contains(order.orderId)) .where((order) => order.orderType == type) .where((order) => order.status == 'pending') @@ -84,6 +86,5 @@ class HomeNotifier extends AsyncNotifier { ); _updateFilteredOrders(allOrders); - } } diff --git a/lib/features/mostro/mostro_settings_widget.dart b/lib/features/mostro/mostro_settings_widget.dart new file mode 100644 index 00000000..17055371 --- /dev/null +++ b/lib/features/mostro/mostro_settings_widget.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; + +class MostroSettingsWidget extends ConsumerWidget { + const MostroSettingsWidget({super.key}); + + + @override + Widget build(BuildContext context, WidgetRef ref) { + + final settings = ref.watch(settingsProvider); + + throw UnimplementedError(); + } + +} \ No newline at end of file diff --git a/lib/features/relays/relays_screen.dart b/lib/features/relays/relays_screen.dart index 7a24cebf..9499f687 100644 --- a/lib/features/relays/relays_screen.dart +++ b/lib/features/relays/relays_screen.dart @@ -3,19 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/features/settings/settings_provider.dart'; -import 'relays_provider.dart'; -import 'relay.dart'; +import 'package:mostro_mobile/features/relays/widgets/relay_selector.dart'; class RelaysScreen extends ConsumerWidget { const RelaysScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - - final settings = ref.watch(settingsProvider); - - final relays = ref.watch(relaysProvider); return Scaffold( appBar: AppBar( backgroundColor: Colors.transparent, @@ -32,117 +26,13 @@ class RelaysScreen extends ConsumerWidget { ), ), backgroundColor: AppTheme.dark1, - body: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: settings.relays.length, - itemBuilder: (context, index) { - final relay = relays[index]; - return Card( - color: AppTheme.dark2, - margin: const EdgeInsets.symmetric(vertical: 8), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: ListTile( - title: Text( - relay.url, - style: const TextStyle(color: Colors.white), - ), - leading: Icon( - Icons.circle, - color: relay.isHealthy ? Colors.green : Colors.red, - size: 16, - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit, color: Colors.white), - onPressed: () { - _showEditDialog(context, relay, ref); - }, - ), - IconButton( - icon: const Icon(Icons.delete, color: Colors.white), - onPressed: () { - ref.read(relaysProvider.notifier).removeRelay(relay.url); - }, - ), - ], - ), - ), - ); - }, - ), + body: RelaySelector(), floatingActionButton: FloatingActionButton( backgroundColor: AppTheme.mostroGreen, child: const Icon(Icons.add), - onPressed: () => _showAddDialog(context, ref), - ), - ); - } - - void _showAddDialog(BuildContext context, WidgetRef ref) { - final controller = TextEditingController(); - showDialog( - useRootNavigator: true, - context: context, - builder: (BuildContext dialogContext) => AlertDialog( - title: const Text('Add Relay'), - content: TextField( - controller: controller, - decoration: const InputDecoration(labelText: 'Relay URL'), - ), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - final url = controller.text.trim(); - if (url.isNotEmpty) { - final newRelay = Relay(url: url, isHealthy: true); - ref.read(relaysProvider.notifier).addRelay(newRelay); - } - context.pop(); - }, - child: const Text('Add'), - ), - ], + onPressed: () => RelaySelector.showAddDialog(context, ref), ), ); } -void _showEditDialog(BuildContext context, Relay relay, WidgetRef ref) { - final controller = TextEditingController(text: relay.url); - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: const Text('Edit Relay'), - content: TextField( - controller: controller, - decoration: const InputDecoration(labelText: 'Relay URL'), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - final newUrl = controller.text.trim(); - if (newUrl.isNotEmpty && newUrl != relay.url) { - final updatedRelay = relay.copyWith(url: newUrl); - ref.read(relaysProvider.notifier).updateRelay(relay, updatedRelay); - } - Navigator.pop(dialogContext); - }, - child: const Text('Save'), - ), - ], - ); - }, - ); -} } diff --git a/lib/features/relays/widgets/relay_selector.dart b/lib/features/relays/widgets/relay_selector.dart index 323f6625..12bb6c41 100644 --- a/lib/features/relays/widgets/relay_selector.dart +++ b/lib/features/relays/widgets/relay_selector.dart @@ -56,7 +56,7 @@ class RelaySelector extends ConsumerWidget { ); } - void _showAddDialog(BuildContext context, WidgetRef ref) { + static void showAddDialog(BuildContext context, WidgetRef ref) { final controller = TextEditingController(); showDialog( useRootNavigator: true, @@ -78,8 +78,8 @@ class RelaySelector extends ConsumerWidget { if (url.isNotEmpty) { final newRelay = Relay(url: url, isHealthy: true); ref.read(relaysProvider.notifier).addRelay(newRelay); + Navigator.pop(dialogContext); } - }, child: const Text('Add'), ), diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart index ffee4a67..3bacdc0e 100644 --- a/lib/features/settings/settings.dart +++ b/lib/features/settings/settings.dart @@ -1,25 +1,34 @@ +import 'package:mostro_mobile/core/config.dart'; + class Settings { final bool fullPrivacyMode; final List relays; + final String mostroInstance; - Settings({required this.relays, required this.fullPrivacyMode}); + Settings( + {required this.relays, + required this.fullPrivacyMode, + required this.mostroInstance}); - Settings copyWith({List? relays, bool? privacyModeSetting}) { + Settings copyWith({List? relays, bool? privacyModeSetting, String? mostroInstance}) { return Settings( relays: relays ?? this.relays, fullPrivacyMode: privacyModeSetting ?? fullPrivacyMode, + mostroInstance: mostroInstance ?? this.mostroInstance, ); } Map toJson() => { 'relays': relays, 'fullPrivacyMode': fullPrivacyMode, + 'mostroInstance': mostroInstance, }; factory Settings.fromJson(Map json) { return Settings( relays: (json['relays'] as List?)?.cast() ?? [], fullPrivacyMode: json['fullPrivacyMode'] as bool, + mostroInstance: json['mostroInstance'] ?? Config.mostroPubKey, ); } } diff --git a/lib/features/settings/settings_notifier.dart b/lib/features/settings/settings_notifier.dart index ba91273b..8b710474 100644 --- a/lib/features/settings/settings_notifier.dart +++ b/lib/features/settings/settings_notifier.dart @@ -17,6 +17,7 @@ class SettingsNotifier extends StateNotifier { return Settings( relays: Config.nostrRelays, fullPrivacyMode: false, + mostroInstance: Config.mostroPubKey, ); } @@ -44,6 +45,11 @@ class SettingsNotifier extends StateNotifier { await _saveToPrefs(); } + Future updateMostroInstanceSetting(String newValue) async { + state = state.copyWith(mostroInstance: newValue); + await _saveToPrefs(); + } + Future _saveToPrefs() async { final jsonString = jsonEncode(state.toJson()); await _prefs.setString(_storageKey, jsonString); diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 4b93198d..37b27e44 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_settings_ui/flutter_settings_ui.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/relays/widgets/relay_selector.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; -import 'package:mostro_mobile/shared/widgets/privacy_switch_widget.dart'; class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @@ -13,6 +13,16 @@ class SettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); + const settingsThemeData = SettingsThemeData( + settingsListBackground: AppTheme.dark2, + settingsSectionBackground: AppTheme.dark1, + titleTextColor: AppTheme.cream1, + settingsTileTextColor: AppTheme.cream1, + leadingIconsColor: AppTheme.cream1, + tileDescriptionTextColor: AppTheme.grey, + ); + final mostroTextContoller = + TextEditingController(text: settings.mostroInstance); return Scaffold( appBar: AppBar( @@ -29,38 +39,65 @@ class SettingsScreen extends ConsumerWidget { ), ), ), - body: ListView( - padding: const EdgeInsets.all(24), - children: [ - Card( - child: ListTile( - title: Text('General Settings'), + body: SettingsList( + lightTheme: settingsThemeData, + sections: [ + SettingsSection( + title: Text( + 'General Settings', + style: AppTheme.theme.textTheme.displayMedium, ), + tiles: [ + SettingsTile.switchTile( + title: Text('Full Privacy Mode'), + leading: HeroIcon(HeroIcons.eye), + initialValue: settings.fullPrivacyMode, + onToggle: (bool value) { + ref + .watch(settingsProvider.notifier) + .updatePrivacyModeSetting(value); + }, + ), + ], ), - const SizedBox(height: 16), - PrivacySwitch( - initialValue: settings.fullPrivacyMode, - onChanged: (newValue) { - ref - .read(settingsProvider.notifier) - .updatePrivacyModeSetting(newValue); - }, - ), - const SizedBox(height: 16), - Card( - child: ListTile( - title: Text('Relays'), + SettingsSection( + title: Text( + 'Relays', + style: AppTheme.theme.textTheme.displayMedium, ), + tiles: [ + CustomSettingsTile( + child: SizedBox( + height: 256, + child: RelaySelector(), + ), + ), + ], ), - SizedBox( - height: 200, - child: RelaySelector(), - ), - const SizedBox(height: 16), - Card( - child: ListTile( - title: Text('Mostro'), + SettingsSection( + title: Text( + 'Mostro', + style: AppTheme.theme.textTheme.displayMedium, ), + tiles: [ + CustomSettingsTile( + child: Padding( + padding: EdgeInsetsDirectional.only( + start: 24, + end: 24, + bottom: 19, + top: 19, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: mostroTextContoller, + ), + ]), + ), + ) + ], ), ], ), diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 7c38cfe5..602583ad 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -122,7 +122,7 @@ class MostroService { final bytes = utf8.encode(serializedEvent); final digest = sha256.convert(bytes); final hash = hex.encode(digest.bytes); - final signature = session.tradeKey.sign(hash); + final signature = session.masterKey.sign(hash); content = jsonEncode([message, signature]); } else { content = jsonEncode([ diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 1bf6be52..effa6064 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:dart_nostr/nostr/model/relay_informations.dart'; import 'package:logger/logger.dart'; @@ -22,6 +23,7 @@ class NostrService { await _nostr.services.relays.init( relaysUrl: settings!.relays, connectionTimeout: Config.nostrConnectionTimeout, + shouldReconnectToRelayOnNotice: true, onRelayListening: (relay, url, channel) { _logger.i('Connected to relay: $relay'); }, @@ -42,10 +44,13 @@ class NostrService { } } - Future updateSettings(Settings settings) async { - _logger.i('Updating settings...'); - this.settings = settings.copyWith(); - await init(); + Future updateSettings(Settings newSettings) async { + settings = newSettings.copyWith(); + final relays = Nostr.instance.services.relays.relaysList; + if (!ListEquality().equals(relays, settings?.relays) ) { + _logger.i('Updating relays...'); + await init(); + } } Future getRelayInfo(String relayUrl) async { diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 32538b48..875397b5 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -12,15 +12,10 @@ import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; final appInitializerProvider = FutureProvider((ref) async { - final settings = ref.read(settingsProvider); final service = ref.read(nostrServiceProvider); await service.updateSettings(settings); - ref.listen(settingsProvider, (previous, next) { - service.updateSettings(next); - }); - final keyManager = ref.read(keyManagerProvider); bool hasMaster = await keyManager.hasMasterKey(); if (!hasMaster) { @@ -30,6 +25,10 @@ final appInitializerProvider = FutureProvider((ref) async { final sessionManager = ref.read(sessionManagerProvider); await sessionManager.init(); + ref.listen(settingsProvider, (previous, next) { + service.updateSettings(next); + sessionManager.fullPrivacyMode = next.fullPrivacyMode; + }); final mostroRepository = ref.read(mostroRepositoryProvider); await mostroRepository.loadMessages(); diff --git a/pubspec.lock b/pubspec.lock index f9787680..122d41de 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -456,6 +456,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_settings_ui: + dependency: "direct main" + description: + name: flutter_settings_ui + sha256: dcc506fab724192594e5c232b6214a941abd6e7b5151626635b89258fadbc17c + url: "https://pub.dev" + source: hosted + version: "3.0.1" flutter_svg: dependency: transitive description: @@ -977,10 +985,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: ea86be7b7114f9e94fddfbb52649e59a03d6627ccd2387ebddcd6624719e9f16 + sha256: a768fc8ede5f0c8e6150476e14f38e2417c0864ca36bb4582be8e21925a03c22 url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "2.4.6" shared_preferences_foundation: dependency: transitive description: @@ -1009,10 +1017,10 @@ packages: dependency: transitive description: name: shared_preferences_web - sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" shared_preferences_windows: dependency: transitive description: @@ -1410,10 +1418,10 @@ packages: dependency: transitive description: name: win32 - sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + sha256: b89e6e24d1454e149ab20fbb225af58660f0c0bf4475544650700d8e2da54aef url: "https://pub.dev" source: hosted - version: "5.10.1" + version: "5.11.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d3bad73f..18ce35d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,6 +75,7 @@ dependencies: ref: dart3a path: app_sembast version: '>=0.1.0' + flutter_settings_ui: ^3.0.1 dev_dependencies: flutter_test: From 0da668439f3525dda197b32ba8981dcb456de185 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 20 Feb 2025 16:52:24 -0800 Subject: [PATCH 057/149] added default relay --- lib/core/config.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/core/config.dart b/lib/core/config.dart index b89c227c..82b2a807 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -3,9 +3,8 @@ import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ - //'wss://relay.mostro.network', - //'wss://nostr.bilthon.dev', - //'ws://127.0.0.1:7000', + 'wss://relay.mostro.network', + //'ws://127.0.0.1:7000', //'ws://192.168.1.148:7000', //'ws://10.0.2.2:7000', // mobile emulator ]; From 710797b6aee7249507f67d7c57161ebacebd47e6 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 20 Feb 2025 23:10:37 -0800 Subject: [PATCH 058/149] Updated Settings Screen --- .github/workflows/main.yml | 97 ++++++++ lib/core/app_theme.dart | 8 +- lib/features/auth/screens/login_screen.dart | 9 +- .../auth/screens/register_screen.dart | 29 +-- lib/features/auth/screens/welcome_screen.dart | 4 +- .../home/notifiers/home_notifier.dart | 2 +- .../home/providers/home_order_providers.dart | 37 +++ lib/features/home/screens/home_screen.dart | 223 +++++++++--------- .../key_manager/key_management_screen.dart | 30 +-- .../screens/messages_detail_screen.dart | 8 +- .../screens/messages_list_screen.dart | 6 +- .../order/screens/add_order_screen.dart | 6 + .../screens/payment_confirmation_screen.dart | 15 +- .../order/widgets/fixed_switch_widget.dart | 3 +- .../relays/widgets/relay_selector.dart | 87 ++++--- lib/features/settings/settings.dart | 13 +- lib/features/settings/settings_notifier.dart | 5 + lib/features/settings/settings_screen.dart | 167 +++++++------ .../trades/screens/trades_screen.dart | 11 +- lib/shared/widgets/bottom_nav_bar.dart | 3 +- .../widgets/clickable_amount_widget.dart | 3 +- .../widgets/custom_elevated_button.dart | 5 +- lib/shared/widgets/exchange_rate_widget.dart | 9 +- lib/shared/widgets/mostro_app_bar.dart | 9 +- lib/shared/widgets/mostro_app_drawer.dart | 9 +- lib/shared/widgets/order_filter.dart | 212 ++++++++++++++--- lib/shared/widgets/privacy_switch_widget.dart | 2 +- test/widget_test.dart | 5 +- 28 files changed, 672 insertions(+), 345 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 lib/features/home/providers/home_order_providers.dart diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..4f572c43 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,97 @@ +name: "Build" + +on: + pull_request: + branches: + - main + push: + branches: + - wip + +jobs: + build: + name: Build & Release + runs-on: macos-latest + + steps: + #1 Checkout Repository + - name: Checkout Repository + uses: actions/checkout@v3 + + #2 Setup Java + - name: Set Up Java + uses: actions/setup-java@v3.12.0 + with: + distribution: 'oracle' + java-version: '17' + + #3 Setup Flutter + - name: Set Up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.29.0' + channel: 'stable' + + #4 Install Dependencies + - name: Install Dependencies + run: flutter pub get + + #5 Building APK + - name: Build APK + run: flutter build apk --release + + #6 Building App Bundle (aab) + - name: Build appBundle + run: flutter build appbundle + + #7 Build IPA ( IOS Build ) + - name: Build IPA + run: flutter build ipa --no-codesign + + - name: Compress Archives and IPAs + run: | + cd build + tar -czf ios_build.tar.gz ios + + #8 Upload Artifacts + - name: Upload Artifacts + uses: actions/upload-artifact@v2 + with: + name: Releases + path: | + build/app/outputs/flutter-apk/app-release.apk + build/app/outputs/bundle/release/app-release.aab + build/ios_build.tar.gz + + #9 Extract Version + - name: Extract version from pubspec.yaml + id: extract_version + run: | + version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r') + echo "VERSION=$version" >> $GITHUB_ENV + + #10 Check if Tag Exists + - name: Check if Tag Exists + id: check_tag + run: | + if git rev-parse "v${{ env.VERSION }}" >/dev/null 2>&1; then + echo "TAG_EXISTS=true" >> $GITHUB_ENV + else + echo "TAG_EXISTS=false" >> $GITHUB_ENV + fi + + #12 Modify Tag if it Exists + - name: Modify Tag + if: env.TAG_EXISTS == 'true' + id: modify_tag + run: | + new_version="${{ env.VERSION }}-build-${{ github.run_number }}" + echo "VERSION=$new_version" >> $GITHUB_ENV + + #13 Create Release + - name: Create Release + uses: ncipollo/release-action@v1 + with: + artifacts: "build/app/outputs/flutter-apk/app-release.apk,build/app/outputs/bundle/release/app-release.aab,build/ios_build.tar.gz" + tag: v${{ github.run_number}} + token: ${{ secrets.TOKEN }} \ No newline at end of file diff --git a/lib/core/app_theme.dart b/lib/core/app_theme.dart index cf359e8f..d939ed65 100644 --- a/lib/core/app_theme.dart +++ b/lib/core/app_theme.dart @@ -53,7 +53,7 @@ class AppTheme { textTheme: _buildTextTheme(), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, + foregroundColor: AppTheme.cream1, backgroundColor: mostroGreen, textStyle: GoogleFonts.robotoCondensed( fontWeight: FontWeight.w500, @@ -133,10 +133,14 @@ class AppTheme { fontWeight: FontWeight.w500, fontSize: 14.0, ), // For secondary text - titleLarge: TextStyle( + titleMedium: TextStyle( fontWeight: FontWeight.w500, fontSize: 16.0, ), // For form labels + titleLarge: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 18.0, + ), // For form labels bodyLarge: TextStyle( fontWeight: FontWeight.w400, fontSize: 16.0, diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart index e5322ce9..3c68ea42 100644 --- a/lib/features/auth/screens/login_screen.dart +++ b/lib/features/auth/screens/login_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/auth/notifiers/auth_state.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_button.dart'; @@ -26,7 +27,7 @@ class LoginScreen extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: const Text('Login', style: TextStyle(color: Colors.white)), + title: const Text('Login', style: TextStyle(color: AppTheme.cream1)), backgroundColor: Colors.transparent, elevation: 0, ), @@ -42,12 +43,12 @@ class LoginScreen extends HookConsumerWidget { controller: pinController, decoration: const InputDecoration( labelText: 'PIN', - labelStyle: TextStyle(color: Colors.white70), + labelStyle: TextStyle(color: AppTheme.cream1), enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white70), + borderSide: BorderSide(color: AppTheme.cream1), ), ), - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), keyboardType: TextInputType.number, obscureText: true, validator: (value) { diff --git a/lib/features/auth/screens/register_screen.dart b/lib/features/auth/screens/register_screen.dart index e9ed0ecd..77d7902b 100644 --- a/lib/features/auth/screens/register_screen.dart +++ b/lib/features/auth/screens/register_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/auth/notifiers/auth_state.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_button.dart'; @@ -46,7 +47,7 @@ class RegisterScreen extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: const Text('Register', style: TextStyle(color: Colors.white)), + title: const Text('Register', style: TextStyle(color: AppTheme.cream1)), backgroundColor: Colors.transparent, elevation: 0, ), @@ -63,16 +64,16 @@ class RegisterScreen extends HookConsumerWidget { controller: privateKeyController, decoration: InputDecoration( labelText: 'Private Key (nsec or hex)', - labelStyle: const TextStyle(color: Colors.white70), + labelStyle: const TextStyle(color: AppTheme.cream1), enabledBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white70), + borderSide: BorderSide(color: AppTheme.cream1), ), suffixIcon: IconButton( icon: Icon( obscurePrivateKey ? Icons.visibility_off : Icons.visibility, - color: Colors.white70, + color: AppTheme.cream1, ), onPressed: () { ref.read(obscurePrivateKeyProvider.notifier).state = @@ -80,7 +81,7 @@ class RegisterScreen extends HookConsumerWidget { }, ), ), - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), obscureText: obscurePrivateKey, validator: (value) { if (value == null || value.isEmpty) { @@ -100,21 +101,21 @@ class RegisterScreen extends HookConsumerWidget { controller: pinController, decoration: InputDecoration( labelText: 'PIN', - labelStyle: const TextStyle(color: Colors.white70), + labelStyle: const TextStyle(color: AppTheme.cream1), enabledBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white70), + borderSide: BorderSide(color: AppTheme.cream1), ), suffixIcon: IconButton( icon: Icon( obscurePin ? Icons.visibility_off : Icons.visibility, - color: Colors.white70, + color: AppTheme.cream1, ), onPressed: () { ref.read(obscurePinProvider.notifier).state = !obscurePin; }, ), ), - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), keyboardType: TextInputType.number, obscureText: obscurePin, validator: (value) { @@ -134,16 +135,16 @@ class RegisterScreen extends HookConsumerWidget { controller: confirmPinController, decoration: InputDecoration( labelText: 'Confirm PIN', - labelStyle: const TextStyle(color: Colors.white70), + labelStyle: const TextStyle(color: AppTheme.cream1), enabledBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.white70), + borderSide: BorderSide(color: AppTheme.cream1), ), suffixIcon: IconButton( icon: Icon( obscureConfirmPin ? Icons.visibility_off : Icons.visibility, - color: Colors.white70, + color: AppTheme.cream1, ), onPressed: () { ref.read(obscureConfirmPinProvider.notifier).state = @@ -151,7 +152,7 @@ class RegisterScreen extends HookConsumerWidget { }, ), ), - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), keyboardType: TextInputType.number, obscureText: obscureConfirmPin, validator: (value) { @@ -173,7 +174,7 @@ class RegisterScreen extends HookConsumerWidget { return biometricsAvailable ? SwitchListTile( title: const Text('Use Biometrics', - style: TextStyle(color: Colors.white)), + style: TextStyle(color: AppTheme.cream1)), value: useBiometrics, onChanged: (bool value) { ref.read(useBiometricsProvider.notifier).state = diff --git a/lib/features/auth/screens/welcome_screen.dart b/lib/features/auth/screens/welcome_screen.dart index a70f64bb..2a5c54f6 100644 --- a/lib/features/auth/screens/welcome_screen.dart +++ b/lib/features/auth/screens/welcome_screen.dart @@ -26,7 +26,7 @@ class WelcomeScreen extends StatelessWidget { 'NO-KYC P2P Lightning\nexchange on top of\nnostr', style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, - color: Colors.white, + color: AppTheme.cream1, ), textAlign: TextAlign.center, ), @@ -51,7 +51,7 @@ class WelcomeScreen extends StatelessWidget { child: const Text( 'Skip for now', style: TextStyle( - color: Colors.white, + color: AppTheme.cream1, decoration: TextDecoration.underline, fontSize: 14, ), diff --git a/lib/features/home/notifiers/home_notifier.dart b/lib/features/home/notifiers/home_notifier.dart index f92db9dd..7204c43b 100644 --- a/lib/features/home/notifiers/home_notifier.dart +++ b/lib/features/home/notifiers/home_notifier.dart @@ -21,7 +21,7 @@ class HomeNotifier extends AsyncNotifier { return HomeState( orderType: OrderType.sell, - filteredOrders: [], + filteredOrders: state.value?.filteredOrders ?? [], ); } diff --git a/lib/features/home/providers/home_order_providers.dart b/lib/features/home/providers/home_order_providers.dart new file mode 100644 index 00000000..68ccfb88 --- /dev/null +++ b/lib/features/home/providers/home_order_providers.dart @@ -0,0 +1,37 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; + +final allOrdersProvider = StreamProvider>((ref) { + final repository = ref.watch(orderRepositoryProvider); + repository.subscribeToOrders(); + return repository.eventsStream; +}); + +final homeOrderTypeProvider = StateProvider((ref) => OrderType.sell); + +final filteredOrdersProvider = Provider>((ref) { + final allOrdersAsync = ref.watch(allOrdersProvider); + final orderType = ref.watch(homeOrderTypeProvider); + final sessionManager = ref.watch(sessionManagerProvider); + + return allOrdersAsync.maybeWhen( + data: (allOrders) { + final orderIds = sessionManager.sessions.map((s) => s.orderId).toSet(); + + allOrders + .sort((o1, o2) => o1.expirationDate.compareTo(o2.expirationDate)); + + final filtered = allOrders.reversed + .where((o) => o.orderType == orderType) + .where((o) => !orderIds.contains(o.orderId)) + .where((o) => o.status == 'pending') + .toList(); + return filtered; + }, + orElse: () => [], + ); +}); diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 3d5b1bfd..16d69e27 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -3,13 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; -import 'package:mostro_mobile/features/home/notifiers/home_notifier.dart'; -import 'package:mostro_mobile/features/home/providers/home_notifer_provider.dart'; -import 'package:mostro_mobile/features/home/notifiers/home_state.dart'; +import 'package:mostro_mobile/features/home/providers/home_order_providers.dart'; +import 'package:mostro_mobile/features/home/widgets/order_list_item.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/order_filter.dart'; -import 'package:mostro_mobile/features/home/widgets/order_list.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; class HomeScreen extends ConsumerWidget { @@ -17,111 +15,129 @@ class HomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final homeStateAsync = ref.watch(homeNotifierProvider); - final homeNotifier = ref.watch(homeNotifierProvider.notifier); + // Watch the filtered orders directly. + final filteredOrders = ref.watch(filteredOrdersProvider); - return homeStateAsync.when( - data: (homeState) { - return Scaffold( - backgroundColor: AppTheme.dark1, - appBar: const MostroAppBar(), - drawer: const MostroAppDrawer(), - body: RefreshIndicator( - onRefresh: () async { - await homeNotifier.refresh(); - }, - child: Container( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), - decoration: BoxDecoration( - color: AppTheme.dark2, - borderRadius: BorderRadius.circular(20), - ), - child: Column( - children: [ - _buildTabs(ref, homeState, homeNotifier), - const SizedBox(height: 12.0), - _buildFilterButton(context, homeState), - const SizedBox(height: 6.0), - Expanded( - child: _buildOrderList(homeState), - ), - const BottomNavBar(), - ], - ), - ), + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: const MostroAppBar(), + drawer: const MostroAppDrawer(), + body: RefreshIndicator( + onRefresh: () async {}, + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.dark2, + borderRadius: BorderRadius.circular(20), ), - ); - }, - loading: () => const Scaffold( - backgroundColor: AppTheme.dark1, - body: Center(child: CircularProgressIndicator()), - ), - error: (error, stack) => Scaffold( - backgroundColor: AppTheme.dark1, - body: Center( - child: Text( - 'Error: $error', - style: const TextStyle(color: AppTheme.cream1), + child: Column( + children: [ + _buildTabs(ref), + const SizedBox(height: 12.0), + _buildFilterButton(context, ref), + const SizedBox(height: 6.0), + Expanded( + child: filteredOrders.isEmpty + ? const Center( + child: Text( + 'No orders available for this type', + style: TextStyle(color: AppTheme.cream1), + ), + ) + : ListView.builder( + itemCount: filteredOrders.length, + itemBuilder: (context, index) { + final order = filteredOrders[index]; + return OrderListItem( + order: order); // Your custom widget + }, + ), + ), + const BottomNavBar(), + ], ), ), ), ); } - Widget _buildTabs( - WidgetRef ref, HomeState homeState, HomeNotifier homeNotifier) { + Widget _buildTabs(WidgetRef ref) { + final orderType = ref.watch(homeOrderTypeProvider); return Container( decoration: const BoxDecoration( - color: AppTheme.dark1, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), - ), + color: AppTheme.dark1, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + )), child: Row( children: [ Expanded( - child: - _buildTab("BUY BTC", homeState.orderType == OrderType.sell, () { - homeNotifier.changeOrderType(OrderType.sell); - }), + child: GestureDetector( + onTap: () => ref.read(homeOrderTypeProvider.notifier).state = + OrderType.sell, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: orderType == OrderType.sell + ? AppTheme.dark2 + : AppTheme.dark1, + borderRadius: BorderRadius.only( + topLeft: + Radius.circular((orderType == OrderType.sell) ? 20 : 0), + topRight: + Radius.circular(orderType == OrderType.sell ? 20 : 0), + ), + ), + child: Text( + "BUY BTC", + textAlign: TextAlign.center, + style: TextStyle( + color: orderType == OrderType.sell + ? AppTheme.mostroGreen + : AppTheme.red1, + fontWeight: FontWeight.bold, + ), + ), + ), + ), ), Expanded( - child: - _buildTab("SELL BTC", homeState.orderType == OrderType.buy, () { - homeNotifier.changeOrderType(OrderType.buy); - }), + child: GestureDetector( + onTap: () => ref.read(homeOrderTypeProvider.notifier).state = + OrderType.buy, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: orderType == OrderType.buy + ? AppTheme.dark2 + : AppTheme.dark1, + borderRadius: BorderRadius.only( + topLeft: + Radius.circular((orderType == OrderType.buy) ? 20 : 0), + topRight: + Radius.circular(orderType == OrderType.buy ? 20 : 0), + ), + ), + child: Text( + "SELL BTC", + textAlign: TextAlign.center, + style: TextStyle( + color: orderType == OrderType.buy + ? AppTheme.mostroGreen + : AppTheme.red1, + fontWeight: FontWeight.bold, + ), + ), + ), + ), ), ], ), ); } - Widget _buildTab(String text, bool isActive, VoidCallback onTap) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: isActive ? AppTheme.dark2 : AppTheme.dark1, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(isActive ? 20 : 0), - topRight: Radius.circular(isActive ? 20 : 0), - ), - ), - child: Text( - text, - textAlign: TextAlign.center, - style: TextStyle( - color: isActive ? AppTheme.mostroGreen : AppTheme.red1, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } - - Widget _buildFilterButton(BuildContext context, HomeState homeState) { + Widget _buildFilterButton(BuildContext context, WidgetRef ref) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( @@ -139,11 +155,17 @@ class HomeScreen extends ConsumerWidget { }, ); }, - icon: const HeroIcon(HeroIcons.funnel, - style: HeroIconStyle.outline, color: Colors.white), - label: const Text("FILTER", style: TextStyle(color: Colors.white)), + icon: const HeroIcon( + HeroIcons.funnel, + style: HeroIconStyle.outline, + color: AppTheme.cream1, + ), + label: const Text( + "FILTER", + style: TextStyle(color: AppTheme.cream1), + ), style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.white), + side: const BorderSide(color: AppTheme.cream1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), @@ -151,24 +173,11 @@ class HomeScreen extends ConsumerWidget { ), const SizedBox(width: 8), Text( - "${homeState.filteredOrders.length} offers", - style: const TextStyle(color: Colors.white), + "${ref.watch(filteredOrdersProvider).length} offers", + style: const TextStyle(color: AppTheme.cream1), ), ], ), ); } - - Widget _buildOrderList(HomeState homeState) { - if (homeState.filteredOrders.isEmpty) { - return const Center( - child: Text( - 'No orders available for this type', - style: TextStyle(color: Colors.white), - ), - ); - } - - return OrderList(orders: homeState.filteredOrders); - } } diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index 350f6007..b36614e2 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; @@ -88,7 +87,7 @@ class _KeyManagementScreenState extends ConsumerState { @override Widget build(BuildContext context) { - + final textTheme = AppTheme.theme.textTheme; return Scaffold( appBar: AppBar( backgroundColor: Colors.transparent, @@ -101,7 +100,6 @@ class _KeyManagementScreenState extends ConsumerState { 'KEY MANAGEMENT', style: TextStyle( color: AppTheme.cream1, - fontFamily: GoogleFonts.robotoCondensed().fontFamily, ), ), ), @@ -109,51 +107,45 @@ class _KeyManagementScreenState extends ConsumerState { body: _loading ? const Center(child: CircularProgressIndicator()) : SingleChildScrollView( - padding: AppTheme.mediumPadding, + padding: AppTheme.largePadding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Master Key - const Text( - 'Master Key', - style: TextStyle(color: AppTheme.cream1, fontSize: 18), - ), + Text('Master Key', style: textTheme.titleLarge), const SizedBox(height: 8), SelectableText( _masterKey ?? '', - style: const TextStyle(color: AppTheme.cream1), ), + const SizedBox(height: 8), TextButton( onPressed: _masterKey != null ? () => _copyToClipboard(_masterKey!, 'Master Key') : null, child: const Text('Copy Master Key'), ), + const SizedBox(height: 8), const Divider(color: AppTheme.grey2), const SizedBox(height: 16), // Mnemonic - const Text( - 'Mnemonic', - style: TextStyle(color: AppTheme.cream1, fontSize: 18), - ), + Text('Mnemonic', style: textTheme.titleLarge), const SizedBox(height: 8), SelectableText( _mnemonic ?? '', - style: const TextStyle(color: AppTheme.cream1), ), + const SizedBox(height: 8), TextButton( onPressed: _mnemonic != null ? () => _copyToClipboard(_mnemonic!, 'Mnemonic') : null, child: const Text('Copy Mnemonic'), ), + const SizedBox(height: 8), const Divider(color: AppTheme.grey2), const SizedBox(height: 16), // Trade Key Index Text( 'Current Trade Key Index: ${_tradeKeyIndex ?? 'N/A'}', - style: - const TextStyle(color: AppTheme.cream1, fontSize: 16), ), const SizedBox(height: 16), // Buttons to generate and delete keys @@ -167,14 +159,10 @@ class _KeyManagementScreenState extends ConsumerState { ), const SizedBox(height: 16), // Import Key - const Text( - 'Import Key from Mnemonic', - style: TextStyle(color: AppTheme.cream1, fontSize: 18), - ), + Text('Import Key from Mnemonic', style: textTheme.titleLarge), const SizedBox(height: 8), TextField( controller: _importController, - style: const TextStyle(color: AppTheme.cream1), decoration: const InputDecoration( labelText: 'Enter key or mnemonic', labelStyle: TextStyle(color: AppTheme.grey2), diff --git a/lib/features/messages/screens/messages_detail_screen.dart b/lib/features/messages/screens/messages_detail_screen.dart index b5a64ba4..3f22c40f 100644 --- a/lib/features/messages/screens/messages_detail_screen.dart +++ b/lib/features/messages/screens/messages_detail_screen.dart @@ -2,6 +2,7 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/messages/notifiers/messages_detail_state.dart'; import 'package:mostro_mobile/features/messages/providers/messages_list_provider.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; @@ -12,7 +13,8 @@ class MessagesDetailScreen extends ConsumerStatefulWidget { const MessagesDetailScreen({super.key, required this.chatId}); @override - ConsumerState createState() => _MessagesDetailScreenState(); + ConsumerState createState() => + _MessagesDetailScreenState(); } class _MessagesDetailScreenState extends ConsumerState { @@ -79,7 +81,7 @@ class _MessagesDetailScreenState extends ConsumerState { ), child: Text( message.content!, - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), ), ), ); @@ -94,7 +96,7 @@ class _MessagesDetailScreenState extends ConsumerState { Expanded( child: TextField( controller: _textController, - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), decoration: InputDecoration( hintText: 'Type a message...', hintStyle: const TextStyle(color: Colors.grey), diff --git a/lib/features/messages/screens/messages_list_screen.dart b/lib/features/messages/screens/messages_list_screen.dart index 52b6470b..ada9f2f1 100644 --- a/lib/features/messages/screens/messages_list_screen.dart +++ b/lib/features/messages/screens/messages_list_screen.dart @@ -73,7 +73,7 @@ class MessagesListScreen extends ConsumerWidget { case MessagesListStatus.empty: return const Center( child: Text('No chats available', - style: TextStyle(color: Colors.white))); + style: TextStyle(color: AppTheme.cream1))); } } } @@ -99,7 +99,7 @@ class ChatListItem extends StatelessWidget { backgroundColor: Colors.grey, child: Text( chat.pubkey.isNotEmpty ? chat.pubkey[0] : '?', - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), ), ), const SizedBox(width: 16), @@ -113,7 +113,7 @@ class ChatListItem extends StatelessWidget { Text( chat.pubkey, style: const TextStyle( - color: Colors.white, + color: AppTheme.cream1, fontWeight: FontWeight.bold, ), ), diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index a34b1a8c..9fd2ebcb 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -91,6 +91,12 @@ class _AddOrderScreenState extends ConsumerState { } Widget _buildTabs(BuildContext context, WidgetRef ref, OrderType orderType) { + final currencyCode = ref.watch(selectedFiatCodeProvider); + if (currencyCode != null && currencyCode.isNotEmpty) { + _isEnabled = true; + } else { + _isEnabled = false; + } return Container( decoration: const BoxDecoration( color: AppTheme.dark1, diff --git a/lib/features/order/screens/payment_confirmation_screen.dart b/lib/features/order/screens/payment_confirmation_screen.dart index e631c441..39d9496b 100644 --- a/lib/features/order/screens/payment_confirmation_screen.dart +++ b/lib/features/order/screens/payment_confirmation_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; @@ -24,10 +25,10 @@ class PaymentConfirmationScreen extends ConsumerWidget { elevation: 0, title: const Text( 'PAYMENT', - style: TextStyle(color: Colors.white), + style: TextStyle(color: AppTheme.cream1), ), leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), + icon: const Icon(Icons.arrow_back, color: AppTheme.cream1), onPressed: () => context.go('/'), ), ), @@ -65,14 +66,14 @@ class PaymentConfirmationScreen extends ConsumerWidget { child: const Icon( Icons.check, size: 50, - color: Colors.white, + color: AppTheme.cream1, ), ), const SizedBox(height: 24), Text( '$satoshis', style: const TextStyle( - color: Colors.white, + color: AppTheme.cream1, fontSize: 24, fontWeight: FontWeight.bold, ), @@ -80,7 +81,7 @@ class PaymentConfirmationScreen extends ConsumerWidget { const Text( 'received', style: TextStyle( - color: Colors.white, + color: AppTheme.cream1, fontSize: 18, ), ), @@ -111,14 +112,14 @@ class PaymentConfirmationScreen extends ConsumerWidget { return Center( child: Text( 'Error: $error', - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), ), ); default: return Center( child: Text( 'Unkown Action: ${state.action}', - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), ), ); } diff --git a/lib/features/order/widgets/fixed_switch_widget.dart b/lib/features/order/widgets/fixed_switch_widget.dart index 07d25f1b..e3077323 100644 --- a/lib/features/order/widgets/fixed_switch_widget.dart +++ b/lib/features/order/widgets/fixed_switch_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class FixedSwitch extends StatefulWidget { final bool initialValue; @@ -41,7 +42,7 @@ class _FixedSwitchState extends State { const SizedBox(width: 8), Text( marketRate ? 'Market' : 'Fixed', - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), ), ], ); diff --git a/lib/features/relays/widgets/relay_selector.dart b/lib/features/relays/widgets/relay_selector.dart index 12bb6c41..709738c8 100644 --- a/lib/features/relays/widgets/relay_selector.dart +++ b/lib/features/relays/widgets/relay_selector.dart @@ -13,46 +13,53 @@ class RelaySelector extends ConsumerWidget { final settings = ref.watch(settingsProvider); final relays = ref.watch(relaysProvider); - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: settings.relays.length, - itemBuilder: (context, index) { - final relay = relays[index]; - return Card( - color: AppTheme.dark2, - margin: const EdgeInsets.symmetric(vertical: 8), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: ListTile( - title: Text( - relay.url, - style: const TextStyle(color: Colors.white), + return AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + itemCount: settings.relays.length, + itemBuilder: (context, index) { + final relay = relays[index]; + return Card( + color: AppTheme.dark2, + margin: const EdgeInsets.symmetric(vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - leading: Icon( - Icons.circle, - color: relay.isHealthy ? Colors.green : Colors.red, - size: 16, + child: ListTile( + title: Text( + relay.url, + style: const TextStyle(color: AppTheme.cream1), + ), + leading: Icon( + Icons.circle, + color: relay.isHealthy ? Colors.green : Colors.red, + size: 16, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, color: AppTheme.cream1), + onPressed: () { + _showEditDialog(context, relay, ref); + }, + ), + IconButton( + icon: const Icon(Icons.delete, color: AppTheme.cream1), + onPressed: () { + ref.read(relaysProvider.notifier).removeRelay(relay.url); + }, + ), + ], + ), ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit, color: Colors.white), - onPressed: () { - _showEditDialog(context, relay, ref); - }, - ), - IconButton( - icon: const Icon(Icons.delete, color: Colors.white), - onPressed: () { - ref.read(relaysProvider.notifier).removeRelay(relay.url); - }, - ), - ], - ), - ), - ); - }, + ); + }, + ), ); } @@ -69,7 +76,9 @@ class RelaySelector extends ConsumerWidget { ), actions: [ TextButton( - onPressed: () {}, + onPressed: () { + Navigator.pop(dialogContext); + }, child: const Text('Cancel'), ), TextButton( diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart index 3bacdc0e..6a23a9f8 100644 --- a/lib/features/settings/settings.dart +++ b/lib/features/settings/settings.dart @@ -4,17 +4,24 @@ class Settings { final bool fullPrivacyMode; final List relays; final String mostroInstance; + final String? defaultFiatCode; Settings( {required this.relays, required this.fullPrivacyMode, - required this.mostroInstance}); + required this.mostroInstance, + this.defaultFiatCode}); - Settings copyWith({List? relays, bool? privacyModeSetting, String? mostroInstance}) { + Settings copyWith( + {List? relays, + bool? privacyModeSetting, + String? mostroInstance, + String? defaultFiatCode}) { return Settings( relays: relays ?? this.relays, fullPrivacyMode: privacyModeSetting ?? fullPrivacyMode, mostroInstance: mostroInstance ?? this.mostroInstance, + defaultFiatCode: defaultFiatCode ?? this.defaultFiatCode, ); } @@ -22,6 +29,7 @@ class Settings { 'relays': relays, 'fullPrivacyMode': fullPrivacyMode, 'mostroInstance': mostroInstance, + 'defaultFiatCode': defaultFiatCode, }; factory Settings.fromJson(Map json) { @@ -29,6 +37,7 @@ class Settings { relays: (json['relays'] as List?)?.cast() ?? [], fullPrivacyMode: json['fullPrivacyMode'] as bool, mostroInstance: json['mostroInstance'] ?? Config.mostroPubKey, + defaultFiatCode: json['defaultFiatCode'], ); } } diff --git a/lib/features/settings/settings_notifier.dart b/lib/features/settings/settings_notifier.dart index 8b710474..932ca885 100644 --- a/lib/features/settings/settings_notifier.dart +++ b/lib/features/settings/settings_notifier.dart @@ -50,6 +50,11 @@ class SettingsNotifier extends StateNotifier { await _saveToPrefs(); } + Future updateDefaultFiatCodeSetting(String newValue) async { + state = state.copyWith(defaultFiatCode: newValue); + await _saveToPrefs(); + } + Future _saveToPrefs() async { final jsonString = jsonEncode(state.toJson()); await _prefs.setString(_storageKey, jsonString); diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 37b27e44..1a72447e 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_settings_ui/flutter_settings_ui.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/relays/widgets/relay_selector.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/shared/widgets/currency_combo_box.dart'; +import 'package:mostro_mobile/shared/widgets/privacy_switch_widget.dart'; class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @@ -13,94 +14,108 @@ class SettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); - const settingsThemeData = SettingsThemeData( - settingsListBackground: AppTheme.dark2, - settingsSectionBackground: AppTheme.dark1, - titleTextColor: AppTheme.cream1, - settingsTileTextColor: AppTheme.cream1, - leadingIconsColor: AppTheme.cream1, - tileDescriptionTextColor: AppTheme.grey, - ); final mostroTextContoller = TextEditingController(text: settings.mostroInstance); + final textTheme = AppTheme.theme.textTheme; return Scaffold( - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), - onPressed: () => context.pop(), - ), - title: const Text( - 'SETTINGS', - style: TextStyle( - color: AppTheme.cream1, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), + onPressed: () => context.pop(), ), - ), - ), - body: SettingsList( - lightTheme: settingsThemeData, - sections: [ - SettingsSection( - title: Text( - 'General Settings', - style: AppTheme.theme.textTheme.displayMedium, + title: const Text( + 'SETTINGS', + style: TextStyle( + color: AppTheme.cream1, ), - tiles: [ - SettingsTile.switchTile( - title: Text('Full Privacy Mode'), - leading: HeroIcon(HeroIcons.eye), - initialValue: settings.fullPrivacyMode, - onToggle: (bool value) { - ref - .watch(settingsProvider.notifier) - .updatePrivacyModeSetting(value); - }, - ), - ], ), - SettingsSection( - title: Text( - 'Relays', - style: AppTheme.theme.textTheme.displayMedium, - ), - tiles: [ - CustomSettingsTile( - child: SizedBox( - height: 256, - child: RelaySelector(), + ), + backgroundColor: AppTheme.dark1, + body: Column( + children: [ + Expanded( + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.dark2, + borderRadius: BorderRadius.circular(20), ), - ), - ], - ), - SettingsSection( - title: Text( - 'Mostro', - style: AppTheme.theme.textTheme.displayMedium, - ), - tiles: [ - CustomSettingsTile( - child: Padding( - padding: EdgeInsetsDirectional.only( - start: 24, - end: 24, - bottom: 19, - top: 19, - ), + child: SingleChildScrollView( + padding: AppTheme.largePadding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField( - controller: mostroTextContoller, + // General Settings + Text('General Settings', style: textTheme.titleLarge), + const SizedBox(height: 8), + PrivacySwitch( + initialValue: settings.fullPrivacyMode, + onChanged: (bool value) { + ref + .watch(settingsProvider.notifier) + .updatePrivacyModeSetting(value); + }), + const SizedBox(height: 8), + CurrencyComboBox( + label: "Default Fiat Currency", + onSelected: (fiatCode) { + ref + .watch(settingsProvider.notifier) + .updateDefaultFiatCodeSetting(fiatCode); + }, + ), + const SizedBox(height: 8), + const Divider(color: AppTheme.grey2), + const SizedBox(height: 16), + // Relays + Text('Relays', style: textTheme.titleLarge), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: AppTheme.dark1, + borderRadius: BorderRadius.circular(24), + ), + child: RelaySelector(), ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton( + onPressed: () { + RelaySelector.showAddDialog(context, ref); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('Add Relay'), + ), + ], + ), + const SizedBox(height: 8), + // Mostro + const Divider(color: AppTheme.grey2), + const SizedBox(height: 16), + Text('Mostro', style: textTheme.titleLarge), + const SizedBox(height: 8), + TextFormField( + key: key, + controller: mostroTextContoller, + style: const TextStyle(color: AppTheme.cream1), + decoration: InputDecoration( + border: InputBorder.none, + labelText: 'Mostro Pubkey', + labelStyle: const TextStyle(color: AppTheme.grey2), + ), + ) ]), ), - ) - ], - ), - ], - ), - ); + ), + ), + ], + )); } } diff --git a/lib/features/trades/screens/trades_screen.dart b/lib/features/trades/screens/trades_screen.dart index f805f983..7faa1cb4 100644 --- a/lib/features/trades/screens/trades_screen.dart +++ b/lib/features/trades/screens/trades_screen.dart @@ -90,10 +90,11 @@ class TradesScreen extends ConsumerWidget { ); }, icon: const HeroIcon(HeroIcons.funnel, - style: HeroIconStyle.outline, color: Colors.white), - label: const Text("FILTER", style: TextStyle(color: Colors.white)), + style: HeroIconStyle.outline, color: AppTheme.cream1), + label: + const Text("FILTER", style: TextStyle(color: AppTheme.cream1)), style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.white), + side: const BorderSide(color: AppTheme.cream1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), @@ -102,7 +103,7 @@ class TradesScreen extends ConsumerWidget { const SizedBox(width: 8), Text( "${homeState.orders.length} trades", - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), ), ], ), @@ -114,7 +115,7 @@ class TradesScreen extends ConsumerWidget { return const Center( child: Text( 'No trades available for this type', - style: TextStyle(color: Colors.white), + style: TextStyle(color: AppTheme.cream1), ), ); } diff --git a/lib/shared/widgets/bottom_nav_bar.dart b/lib/shared/widgets/bottom_nav_bar.dart index 9b15df0e..9b698338 100644 --- a/lib/shared/widgets/bottom_nav_bar.dart +++ b/lib/shared/widgets/bottom_nav_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class BottomNavBar extends StatelessWidget { const BottomNavBar({super.key}); @@ -14,7 +15,7 @@ class BottomNavBar extends StatelessWidget { height: 56, width: 240, decoration: BoxDecoration( - color: Colors.white, + color: AppTheme.cream1, borderRadius: BorderRadius.circular(28), ), child: Row( diff --git a/lib/shared/widgets/clickable_amount_widget.dart b/lib/shared/widgets/clickable_amount_widget.dart index c45ef13b..4787e1ae 100644 --- a/lib/shared/widgets/clickable_amount_widget.dart +++ b/lib/shared/widgets/clickable_amount_widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; class ClickableAmountText extends StatefulWidget { final String leftText; @@ -49,7 +50,7 @@ class _ClickableAmountTextState extends State { text: TextSpan( style: DefaultTextStyle.of(context) .style - .copyWith(fontSize: 16, color: Colors.white), + .copyWith(fontSize: 16, color: AppTheme.cream1), children: [ TextSpan(text: widget.leftText), TextSpan( diff --git a/lib/shared/widgets/custom_elevated_button.dart b/lib/shared/widgets/custom_elevated_button.dart index 03ca9bc1..4af76766 100644 --- a/lib/shared/widgets/custom_elevated_button.dart +++ b/lib/shared/widgets/custom_elevated_button.dart @@ -22,10 +22,11 @@ class CustomElevatedButton extends StatelessWidget { return ElevatedButton( onPressed: onPressed, style: ElevatedButton.styleFrom( - foregroundColor: foregroundColor ?? Colors.white, + foregroundColor: foregroundColor ?? AppTheme.cream1, backgroundColor: backgroundColor ?? AppTheme.mostroGreen, textStyle: Theme.of(context).textTheme.labelLarge, - padding: padding ?? const EdgeInsets.symmetric(vertical: 15, horizontal: 30), + padding: + padding ?? const EdgeInsets.symmetric(vertical: 15, horizontal: 30), ), child: Text(text), ); diff --git a/lib/shared/widgets/exchange_rate_widget.dart b/lib/shared/widgets/exchange_rate_widget.dart index 2a8494d3..70d859a7 100644 --- a/lib/shared/widgets/exchange_rate_widget.dart +++ b/lib/shared/widgets/exchange_rate_widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; class ExchangeRateWidget extends ConsumerWidget { @@ -34,12 +35,12 @@ class ExchangeRateWidget extends ConsumerWidget { symbol: '', decimalDigits: 2, ).format(exchangeRate)} $currency', - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), ), Row( children: [ Text('price in $currency', - style: const TextStyle(color: Colors.grey)), + style: const TextStyle(color: AppTheme.grey)), const SizedBox(width: 4), GestureDetector( onTap: () { @@ -57,12 +58,12 @@ class ExchangeRateWidget extends ConsumerWidget { child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: Colors.grey.withValues(alpha: .3), + color: AppTheme.grey.withValues(alpha: .3), shape: BoxShape.circle, ), child: const Icon( Icons.sync, - color: Colors.white, + color: AppTheme.cream1, size: 12, ), ), diff --git a/lib/shared/widgets/mostro_app_bar.dart b/lib/shared/widgets/mostro_app_bar.dart index f2c230d8..b2ed3d38 100644 --- a/lib/shared/widgets/mostro_app_bar.dart +++ b/lib/shared/widgets/mostro_app_bar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; @@ -11,11 +12,11 @@ class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { @override Widget build(BuildContext context, WidgetRef ref) { return AppBar( - backgroundColor: const Color(0xFF1D212C), + backgroundColor: AppTheme.dark1, elevation: 0, leading: IconButton( icon: const HeroIcon(HeroIcons.bars3, - style: HeroIconStyle.outline, color: Colors.white), + style: HeroIconStyle.outline, color: AppTheme.cream1), onPressed: () { Scaffold.of(context).openDrawer(); }, @@ -24,14 +25,14 @@ class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { IconButton( key: Key('createOrderButton'), icon: const HeroIcon(HeroIcons.plus, - style: HeroIconStyle.outline, color: Colors.white), + style: HeroIconStyle.outline, color: AppTheme.cream1), onPressed: () { context.go('/add_order'); }, ), IconButton( icon: const HeroIcon(HeroIcons.bolt, - style: HeroIconStyle.solid, color: Color(0xFF8CC541)), + style: HeroIconStyle.solid, color: AppTheme.yellow), onPressed: () async { final mostroStorage = ref.watch(mostroStorageProvider); await clearAppData(mostroStorage); diff --git a/lib/shared/widgets/mostro_app_drawer.dart b/lib/shared/widgets/mostro_app_drawer.dart index 1f57c499..a11872b7 100644 --- a/lib/shared/widgets/mostro_app_drawer.dart +++ b/lib/shared/widgets/mostro_app_drawer.dart @@ -17,14 +17,7 @@ class MostroAppDrawer extends StatelessWidget { image: const DecorationImage( image: AssetImage('assets/images/logo.png'), fit: BoxFit.scaleDown)), - child: Stack( - ), - ), - ListTile( - title: const Text('Relays'), - onTap: () { - context.push('/relays'); - }, + child: Stack(), ), ListTile( title: const Text('Key Management'), diff --git a/lib/shared/widgets/order_filter.dart b/lib/shared/widgets/order_filter.dart index c15f56d1..a3ae7421 100644 --- a/lib/shared/widgets/order_filter.dart +++ b/lib/shared/widgets/order_filter.dart @@ -2,14 +2,135 @@ import 'package:flutter/material.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -class OrderFilter extends StatelessWidget { +/// A custom multi-select field based on Autocomplete. +/// It lets the user type to filter options and add selections which are shown as Chips. +class MultiSelectAutocomplete extends StatefulWidget { + final String label; + final List options; + final List selectedValues; + final ValueChanged> onChanged; + + const MultiSelectAutocomplete({ + super.key, + required this.label, + required this.options, + required this.selectedValues, + required this.onChanged, + }); + + @override + MultiSelectAutocompleteState createState() => + MultiSelectAutocompleteState(); +} + +class MultiSelectAutocompleteState extends State { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + //_controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: const TextStyle(color: AppTheme.mostroGreen), + ), + const SizedBox(height: 8), + Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + return widget.options.where((option) => + option + .toLowerCase() + .contains(textEditingValue.text.toLowerCase()) && + !widget.selectedValues.contains(option)); + }, + onSelected: (String selection) { + final updated = List.from(widget.selectedValues) + ..add(selection); + widget.onChanged(updated); + _controller.clear(); + }, + fieldViewBuilder: + (context, textEditingController, focusNode, onFieldSubmitted) { + _controller = textEditingController; + return TextFormField( + controller: textEditingController, + focusNode: focusNode, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: 'Type to add...', + ), + ); + }, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: widget.selectedValues.isEmpty + ? [ + const Text( + 'None selected', + style: TextStyle(color: AppTheme.cream1), + ) + ] + : widget.selectedValues + .map((value) => Chip( + label: Text(value), + onDeleted: () { + final updated = List.from(widget.selectedValues) + ..remove(value); + widget.onChanged(updated); + }, + )) + .toList(), + ), + const SizedBox(height: 8), + ], + ); + } +} + +/// The updated OrderFilter widget which uses the MultiSelectAutocomplete widgets and a slider. +class OrderFilter extends StatefulWidget { const OrderFilter({super.key}); + @override + OrderFilterState createState() => OrderFilterState(); +} + +class OrderFilterState extends State { + List selectedFiatCurrencies = []; + List selectedPaymentMethods = []; + double rating = 0.0; + + // Options for the multi-select fields. + final List fiatOptions = ['USD', 'EUR', 'VES']; + final List paymentMethodsOptions = [ + 'face to face', + 'bank transfer', + 'lightning' + ]; + @override Widget build(BuildContext context) { return Container( width: 300, - padding: EdgeInsets.all(16), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppTheme.cream1, borderRadius: BorderRadius.circular(10), @@ -17,7 +138,7 @@ class OrderFilter extends StatelessWidget { BoxShadow( color: Colors.black.withValues(alpha: .1), blurRadius: 5, - offset: Offset(0, 3), + offset: const Offset(0, 3), ), ], ), @@ -25,58 +146,81 @@ class OrderFilter extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // Header with title and close button. Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( - children: [ - const HeroIcon(HeroIcons.funnel, + children: const [ + HeroIcon(HeroIcons.funnel, style: HeroIconStyle.outline, color: AppTheme.dark2), SizedBox(width: 8), Text( 'FILTER', - style: AppTheme.theme.textTheme.headlineSmall!.copyWith( + style: TextStyle( color: AppTheme.dark2, + fontSize: 18, + fontWeight: FontWeight.bold, ), ), ], ), IconButton( - icon: Icon(Icons.close, color: AppTheme.dark2, size: 20), + icon: + const 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, '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) { }, + const SizedBox(height: 20), + // Fiat currencies using Autocomplete multi-select. + MultiSelectAutocomplete( + label: 'Fiat currencies', + options: fiatOptions, + selectedValues: selectedFiatCurrencies, + onChanged: (values) { + setState(() { + selectedFiatCurrencies = values; + }); + }, + ), + const SizedBox(height: 12), + // Payment methods using Autocomplete multi-select. + MultiSelectAutocomplete( + label: 'Payment methods', + options: paymentMethodsOptions, + selectedValues: selectedPaymentMethods, + onChanged: (values) { + setState(() { + selectedPaymentMethods = values; + }); + }, + ), + const SizedBox(height: 12), + // Rating slider between 0 and 5. + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Rating: ${rating.toStringAsFixed(1)}", + style: const TextStyle(color: AppTheme.mostroGreen), + ), + Slider( + value: rating, + min: 0, + max: 5, + divisions: 5, + label: rating.toStringAsFixed(1), + onChanged: (val) { + setState(() { + rating = val; + }); + }, + ) + ], ), - SizedBox(height: 4), ], ), ); diff --git a/lib/shared/widgets/privacy_switch_widget.dart b/lib/shared/widgets/privacy_switch_widget.dart index 2bf92cd6..f1541ab2 100644 --- a/lib/shared/widgets/privacy_switch_widget.dart +++ b/lib/shared/widgets/privacy_switch_widget.dart @@ -52,7 +52,7 @@ class _PrivacySwitchState extends State { // A text label that changes based on the switch value. Text( _isFullPrivacy ? 'Full Privacy' : 'Normal Privacy', - style: const TextStyle(color: Colors.white), + style: const TextStyle(color: AppTheme.cream1), ), ], ); diff --git a/test/widget_test.dart b/test/widget_test.dart index 61b3ed79..170015b6 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -7,13 +7,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:mostro_mobile/main.dart'; +import 'package:mostro_mobile/core/app.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(const MostroApp()); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget); From d5616025e6a2f0030968f50032e88f63a8a9bbbf Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 20 Feb 2025 23:18:56 -0800 Subject: [PATCH 059/149] update build workflow --- .github/workflows/main.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4f572c43..2b0f634a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -55,7 +55,7 @@ jobs: #8 Upload Artifacts - name: Upload Artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: Releases path: | @@ -78,7 +78,6 @@ jobs: echo "TAG_EXISTS=true" >> $GITHUB_ENV else echo "TAG_EXISTS=false" >> $GITHUB_ENV - fi #12 Modify Tag if it Exists - name: Modify Tag @@ -93,5 +92,4 @@ jobs: uses: ncipollo/release-action@v1 with: artifacts: "build/app/outputs/flutter-apk/app-release.apk,build/app/outputs/bundle/release/app-release.aab,build/ios_build.tar.gz" - tag: v${{ github.run_number}} - token: ${{ secrets.TOKEN }} \ No newline at end of file + tag: v${{ env.VERSION }} \ No newline at end of file From ae1dd1f620650402491d05e49812eb47df29c3fc Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 20 Feb 2025 23:45:39 -0800 Subject: [PATCH 060/149] More buildflow fixes --- .github/workflows/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2b0f634a..b634ad85 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -78,13 +78,14 @@ jobs: echo "TAG_EXISTS=true" >> $GITHUB_ENV else echo "TAG_EXISTS=false" >> $GITHUB_ENV + fi #12 Modify Tag if it Exists - name: Modify Tag if: env.TAG_EXISTS == 'true' id: modify_tag run: | - new_version="${{ env.VERSION }}-build-${{ github.run_number }}" + new_version="${{ env.VERSION }}-alpha-${{ github.run_number }}" echo "VERSION=$new_version" >> $GITHUB_ENV #13 Create Release From f37eb89ff7d7c364247d5449386b27058df85b85 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Fri, 21 Feb 2025 11:42:09 -0800 Subject: [PATCH 061/149] Updated Take Order Screen and Home Screen Order List --- .../home/notifiers/home_notifier.dart | 90 ------- lib/features/home/notifiers/home_state.dart | 30 --- .../home/providers/home_notifer_provider.dart | 10 - lib/features/home/widgets/order_list.dart | 19 -- .../home/widgets/order_list_item.dart | 98 +++---- .../order/screens/take_order_screen.dart | 241 ++++++++---------- lib/shared/utils/currency_utils.dart | 16 ++ .../widgets/pay_lightning_invoice_widget.dart | 2 +- pubspec.lock | 16 +- pubspec.yaml | 2 +- 10 files changed, 165 insertions(+), 359 deletions(-) delete mode 100644 lib/features/home/notifiers/home_notifier.dart delete mode 100644 lib/features/home/notifiers/home_state.dart delete mode 100644 lib/features/home/providers/home_notifer_provider.dart delete mode 100644 lib/features/home/widgets/order_list.dart create mode 100644 lib/shared/utils/currency_utils.dart diff --git a/lib/features/home/notifiers/home_notifier.dart b/lib/features/home/notifiers/home_notifier.dart deleted file mode 100644 index 7204c43b..00000000 --- a/lib/features/home/notifiers/home_notifier.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:async'; -import 'package:dart_nostr/nostr/model/event/event.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/enums/order_type.dart'; -import 'package:mostro_mobile/data/models/nostr_event.dart'; -import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; -import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; -import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; -import 'home_state.dart'; - -class HomeNotifier extends AsyncNotifier { - StreamSubscription>? _subscription; - OpenOrdersRepository? _repository; - - @override - Future build() async { - state = const AsyncLoading(); - _repository = ref.watch(orderRepositoryProvider); - - _subscribeToOrderUpdates(); - - return HomeState( - orderType: OrderType.sell, - filteredOrders: state.value?.filteredOrders ?? [], - ); - } - - void _subscribeToOrderUpdates() { - _subscription?.cancel(); - _repository!.subscribeToOrders(); - - _subscription = _repository!.eventsStream.listen( - (orders) { - _updateFilteredOrders(orders); - }, - onError: (error) { - state = AsyncError(error, StackTrace.current); - }, - ); - - ref.onDispose(() => _subscription?.cancel()); - } - - /// Refreshes the data by fetching new orders without rebuilding the whole notifier. - Future refresh() async { - final orders = _repository?.currentEvents; - if (orders != null) { - _updateFilteredOrders(orders); - } - } - - void _updateFilteredOrders(List orders) { - final orderType = state.value?.orderType ?? OrderType.sell; - final filteredOrders = _filterOrders(orders, orderType); - - state = AsyncData( - HomeState( - orderType: orderType, - filteredOrders: filteredOrders, - ), - ); - } - - List _filterOrders(List orders, OrderType type) { - final sessionManager = ref.watch(sessionManagerProvider); - final orderIds = sessionManager.sessions.map((s) => s.orderId).toSet(); - - orders.sort((o1, o2) => o1.expirationDate.compareTo(o2.expirationDate)); - - return orders.reversed - .where((order) => !orderIds.contains(order.orderId)) - .where((order) => order.orderType == type) - .where((order) => order.status == 'pending') - .toList(); - } - - void changeOrderType(OrderType type) { - final currentState = state.value; - if (currentState == null || _repository == null) return; - final allOrders = _repository!.currentEvents; - - state = AsyncData( - currentState.copyWith( - orderType: type, - ), - ); - - _updateFilteredOrders(allOrders); - } -} diff --git a/lib/features/home/notifiers/home_state.dart b/lib/features/home/notifiers/home_state.dart deleted file mode 100644 index 3fe08f7c..00000000 --- a/lib/features/home/notifiers/home_state.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; -import 'package:mostro_mobile/data/models/enums/order_type.dart'; - -class HomeState { - final OrderType orderType; - final List filteredOrders; - final bool isLoading; - final String? error; - - HomeState({ - required this.orderType, - required this.filteredOrders, - this.isLoading = false, - this.error, - }); - - HomeState copyWith({ - OrderType? orderType, - List? filteredOrders, - bool? isLoading, - String? error, - }) { - return HomeState( - orderType: orderType ?? this.orderType, - filteredOrders: filteredOrders ?? this.filteredOrders, - isLoading: isLoading ?? this.isLoading, - error: error, - ); - } -} diff --git a/lib/features/home/providers/home_notifer_provider.dart b/lib/features/home/providers/home_notifer_provider.dart deleted file mode 100644 index c1c53df0..00000000 --- a/lib/features/home/providers/home_notifer_provider.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/home/notifiers/home_notifier.dart'; -import 'package:mostro_mobile/features/home/notifiers/home_state.dart'; - - -final homeNotifierProvider = - AsyncNotifierProvider( - HomeNotifier.new, -); - diff --git a/lib/features/home/widgets/order_list.dart b/lib/features/home/widgets/order_list.dart deleted file mode 100644 index 24fd5602..00000000 --- a/lib/features/home/widgets/order_list.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; -import 'package:flutter/material.dart'; -import 'package:mostro_mobile/features/home/widgets/order_list_item.dart'; - -class OrderList extends StatelessWidget { - final List orders; - - const OrderList({super.key, required this.orders}); - - @override - Widget build(BuildContext context) { - return ListView.builder( - itemCount: orders.length, - itemBuilder: (context, index) { - return OrderListItem(order: orders[index]); - }, - ); - } -} diff --git a/lib/features/home/widgets/order_list_item.dart b/lib/features/home/widgets/order_list_item.dart index df7f3b1d..80972e7b 100644 --- a/lib/features/home/widgets/order_list_item.dart +++ b/lib/features/home/widgets/order_list_item.dart @@ -5,6 +5,7 @@ import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/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/shared/utils/currency_utils.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; class OrderListItem extends StatelessWidget { @@ -27,55 +28,36 @@ class OrderListItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildHeader(context), - const SizedBox(height: 16), - _buildOrderDetails(context), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(order.orderType == OrderType.buy ? 'buying' : 'selling'), + Text('${order.expiration}'), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + _getOrderOffering(context, order), + const SizedBox(width: 16), + ], + ), + const SizedBox(height: 8), + _buildPaymentMethod(context), const SizedBox(height: 8), + Row( + children: [ + Text( + '${order.rating?.totalRating ?? 0.0} ${getStars(order.rating?.totalRating ?? 0.0)}'), + ], + ), ], ), ), ); } - Widget _buildHeader(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${order.name} ${order.rating?.totalRating ?? 0}/${order.rating?.maxRate ?? 5} (${order.rating?.totalReviews ?? 0})', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.cream1, - ), - ), - Text( - 'Time: ${order.expiration}', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.cream1, - ), - ), - ], - ); - } - - Widget _buildOrderDetails(BuildContext context) { - return Row( - children: [ - _getOrderOffering(context, order), - const SizedBox(width: 16), - Expanded( - flex: 4, - child: _buildPaymentMethod(context), - ), - ], - ); - } - Widget _getOrderOffering(BuildContext context, NostrEvent order) { - String offering = order.orderType == OrderType.buy ? 'Buying' : 'Selling'; - String amountText = (order.amount != null && order.amount != '0') - ? ' ${order.amount!}' - : ''; - return Expanded( flex: 3, child: Column( @@ -86,34 +68,14 @@ class OrderListItem extends StatelessWidget { children: [ _buildStyledTextSpan( context, - offering, - amountText, - isValue: true, - isBold: true, - ), - TextSpan( - text: 'sats', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.cream1, - fontWeight: FontWeight.normal, - ), - ), - ], - ), - ), - const SizedBox(height: 8.0), - RichText( - text: TextSpan( - children: [ - _buildStyledTextSpan( - context, - 'for ', + ' ', '${order.fiatAmount}', isValue: true, isBold: true, ), TextSpan( - text: '${order.currency} ', + text: + '${order.currency} ${CurrencyUtils.getFlagFromCurrency(order.currency!)} ', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: AppTheme.cream1, fontSize: 16.0, @@ -156,9 +118,7 @@ class OrderListItem extends StatelessWidget { Flexible( child: Text( methods, - style: AppTheme.theme.textTheme.bodyMedium?.copyWith( - color: AppTheme.grey2, - ), + style: AppTheme.theme.textTheme.bodySmall, overflow: TextOverflow.fade, softWrap: true, ), @@ -207,4 +167,8 @@ class OrderListItem extends StatelessWidget { : [], ); } + + String getStars(double count) { + return count > 0 ? '⭐' * count.toInt() : ''; + } } diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 7358f11e..9e95de31 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -1,18 +1,17 @@ +import 'package:circular_countdown/circular_countdown.dart'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:heroicons/heroicons.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/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/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; -import 'package:mostro_mobile/features/order/widgets/seller_info.dart'; -import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; -import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/utils/currency_utils.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; class TakeOrderScreen extends ConsumerWidget { @@ -29,8 +28,7 @@ class TakeOrderScreen extends ConsumerWidget { return Scaffold( backgroundColor: AppTheme.dark1, - appBar: OrderAppBar( - title: '${orderType == OrderType.buy ? "SELL" : "BUY"} BITCOIN'), + appBar: OrderAppBar(title: 'ORDER DETAILS'), body: orderAsyncValue.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => Center(child: Text('Error: $error')), @@ -43,19 +41,13 @@ class TakeOrderScreen extends ConsumerWidget { padding: const EdgeInsets.all(16.0), child: Column( children: [ - CustomCard( - padding: const EdgeInsets.all(16), - child: SellerInfo(order: order), - ), const SizedBox(height: 16), _buildSellerAmount(ref, order), const SizedBox(height: 16), - _buildPaymentMethod(order), - const SizedBox(height: 16), - if ((orderType == OrderType.sell && order.amount != '0') || - order.fiatAmount.maximum != null) - _buildBuyerAmount(int.tryParse(order.amount!)), - _buildLnAddress(), + _buildOrderId(context), + const SizedBox(height: 24), + _buildCountDownTime(order.expirationDate), + const SizedBox(height: 36), _buildActionButtons(context, ref, order.orderId!), ], ), @@ -66,134 +58,101 @@ class TakeOrderScreen extends ConsumerWidget { } Widget _buildSellerAmount(WidgetRef ref, NostrEvent order) { - final exchangeRateAsyncValue = - ref.watch(exchangeRateProvider(order.currency!)); - return exchangeRateAsyncValue.when( - loading: () => const CircularProgressIndicator(), - error: (error, _) => Text('Exchange rate error: $error'), - data: (exchangeRate) { - final sats = (100000000 / exchangeRate) * order.fiatAmount.minimum; - return CustomCard( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${order.fiatAmount} ${order.currency} (${order.premium}%)', - style: textTheme.displayLarge, - ), - Text( - '${NumberFormat.currency( - symbol: '', - decimalDigits: 2, - ).format(sats)} sats', - style: const TextStyle(color: AppTheme.cream1), - ), - Text( - '1 BTC = ${NumberFormat.currency( - symbol: '', - decimalDigits: 2, - ).format(exchangeRate)} ${order.currency}', - style: const TextStyle(color: Colors.grey), - ), - ], - ) - ], - ), - ); - }, - ); - } - - Widget _buildPaymentMethod(NostrEvent order) { - String method = order.paymentMethods.isNotEmpty + final selling = orderType == OrderType.sell ? 'selling' : 'buying'; + final amountString = + '${order.fiatAmount} ${order.currency} ${CurrencyUtils.getFlagFromCurrency(order.currency!)}'; + final satAmount = order.amount == '0' ? '' : ' ${order.amount}'; + final price = order.amount != '0' ? '' : 'at market price'; + final premium = int.parse(order.premium ?? '0'); + final premiumText = premium >= 0 + ? premium == 0 + ? '' + : 'with a +{premium}% premium' + : 'with a -{premium}% discount'; + final method = order.paymentMethods.isNotEmpty ? order.paymentMethods[0] : 'No payment method'; - String methods = order.paymentMethods.join('\n'); + return CustomCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + spacing: 2, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Someone is $selling$satAmount sats for $amountString $price $premiumText', + style: AppTheme.theme.textTheme.bodyLarge, + softWrap: true, + ), + const SizedBox(height: 16), + Text( + 'Created on: ${formatDateTime(order.createdAt!)}', + style: textTheme.bodyLarge, + ), + const SizedBox(height: 16), + Text( + 'The payment method is: $method', + style: textTheme.bodyLarge, + ), + ], + ), + ), + ], + ), + ); + } + Widget _buildOrderId(BuildContext context) { return CustomCard( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(2), child: Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: HeroIcon( - _getPaymentMethodIcon(method), - style: HeroIconStyle.outline, - color: AppTheme.cream1, - size: 16, - ), + SelectableText( + orderId, + style: TextStyle(color: AppTheme.mostroGreen), ), - const SizedBox(width: 4), - Flexible( - child: Text( - methods, - style: AppTheme.theme.textTheme.bodyMedium, + const SizedBox(width: 16), + IconButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: orderId)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Order ID copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + }, + icon: const Icon(Icons.copy), + style: IconButton.styleFrom( + foregroundColor: AppTheme.mostroGreen, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), - ), + ) ], )); } - HeroIcons _getPaymentMethodIcon(String method) { - switch (method.toLowerCase()) { - case 'wire transfer': - case 'transferencia bancaria': - return HeroIcons.buildingLibrary; - case 'revolut': - return HeroIcons.creditCard; - default: - return HeroIcons.banknotes; + Widget _buildCountDownTime(DateTime expiration) { + Duration countdown = Duration(hours: 0); + final now = DateTime.now(); + if (expiration.isAfter(now)) { + countdown = expiration.difference(now); } - } - - Widget _buildBuyerAmount(int? amount) { - return Column(children: [ - CustomCard( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CurrencyTextField( - controller: _fiatAmountController, - label: 'Enter a Fiat amount'), - const SizedBox(height: 8), - ], - ), - ), - const SizedBox(height: 16), - ]); - } - Widget _buildLnAddress() { return Column( children: [ - CustomCard( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - controller: _lndAddressController, - style: const TextStyle(color: AppTheme.cream1), - decoration: InputDecoration( - labelText: "Enter a Lightning Address", - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a value'; - } - return null; - }, - ), - const SizedBox(height: 8), - ], - ), + CircularCountdown( + countdownTotal: 24, + countdownRemaining: countdown.inHours, ), - const SizedBox(height: 24), + const SizedBox(height: 16), + Text('Time Left: ${countdown.toString().split('.')[0]}'), ], ); } @@ -204,16 +163,14 @@ class TakeOrderScreen extends ConsumerWidget { ref.read(orderNotifierProvider(orderId).notifier); return Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton( + OutlinedButton( onPressed: () { context.go('/'); }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - ), - child: const Text('CANCEL'), + style: AppTheme.theme.outlinedButtonTheme.style, + child: const Text('CLOSE'), ), const SizedBox(width: 16), // Take Order @@ -231,9 +188,27 @@ class TakeOrderScreen extends ConsumerWidget { style: ElevatedButton.styleFrom( backgroundColor: AppTheme.mostroGreen, ), - child: const Text('CONTINUE'), + child: const Text('TAKE'), ), ], ); } + + String formatDateTime(DateTime dt) { + // Format the main parts (e.g. Mon Dec 30 2024 08:16:00) + final dateFormatter = DateFormat('EEE MMM dd yyyy HH:mm:ss'); + final formattedDate = dateFormatter.format(dt); + + // Get the timezone offset in hours and minutes. + final offset = dt.timeZoneOffset; + final sign = offset.isNegative ? '-' : '+'; + // Absolute values so the sign is handled separately. + final hours = offset.inHours.abs().toString().padLeft(2, '0'); + final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, '0'); + + // Get the timezone abbreviation + final timeZoneName = dt.timeZoneName; + + return '$formattedDate GMT $sign$hours$minutes ($timeZoneName)'; + } } diff --git a/lib/shared/utils/currency_utils.dart b/lib/shared/utils/currency_utils.dart new file mode 100644 index 00000000..4441cc84 --- /dev/null +++ b/lib/shared/utils/currency_utils.dart @@ -0,0 +1,16 @@ +class CurrencyUtils { + + static String getFlagEmoji(String countryCode) { + return countryCode + .toUpperCase() + .split('') + .map((char) => String.fromCharCode( + 0x1F1E6 + char.codeUnitAt(0) - 'A'.codeUnitAt(0))) + .join(); + } + + static String? getFlagFromCurrency(String currencyCode) { + String? countryCode = currencyCode.toUpperCase().substring(0, 2); + return getFlagEmoji(countryCode); + } +} diff --git a/lib/shared/widgets/pay_lightning_invoice_widget.dart b/lib/shared/widgets/pay_lightning_invoice_widget.dart index dd914ce8..966b91b6 100644 --- a/lib/shared/widgets/pay_lightning_invoice_widget.dart +++ b/lib/shared/widgets/pay_lightning_invoice_widget.dart @@ -61,7 +61,7 @@ class _PayLightningInvoiceWidgetState extends State { .i('Copied LN Invoice to clipboard: ${widget.lnInvoice}'); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Invoice copied to clipboard!'), + content: Text('Invoice copied to clipboard'), duration: Duration(seconds: 2), ), ); diff --git a/pubspec.lock b/pubspec.lock index 122d41de..a5edb11b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -193,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + circular_countdown: + dependency: "direct main" + description: + name: circular_countdown + sha256: "471e6fdcf36f35ce6d1003ca99b009b940195ed63847f67b775ed1a75ed487cf" + url: "https://pub.dev" + source: hosted + version: "2.1.0" cli_util: dependency: transitive description: @@ -456,14 +464,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" - flutter_settings_ui: - dependency: "direct main" - description: - name: flutter_settings_ui - sha256: dcc506fab724192594e5c232b6214a941abd6e7b5151626635b89258fadbc17c - url: "https://pub.dev" - source: hosted - version: "3.0.1" flutter_svg: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 18ce35d1..aff91bc7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,7 +75,7 @@ dependencies: ref: dart3a path: app_sembast version: '>=0.1.0' - flutter_settings_ui: ^3.0.1 + circular_countdown: ^2.1.0 dev_dependencies: flutter_test: From 58d0bcb253161ca76913806b34e5db589d1c4cae Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Fri, 21 Feb 2025 15:08:36 -0800 Subject: [PATCH 062/149] improve Lightning Invoice Screen --- lib/core/app_theme.dart | 1 - .../screens/add_lightning_invoice_screen.dart | 66 +++++++++++-------- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/lib/core/app_theme.dart b/lib/core/app_theme.dart index d939ed65..168aef68 100644 --- a/lib/core/app_theme.dart +++ b/lib/core/app_theme.dart @@ -114,7 +114,6 @@ class AppTheme { return GoogleFonts.robotoCondensedTextTheme( const TextTheme( displayLarge: TextStyle( - fontWeight: FontWeight.w700, fontSize: 24.0, ), // For larger titles displayMedium: TextStyle( diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 0aaf3b92..235c8e24 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -4,7 +4,6 @@ import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; -import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart'; class AddLightningInvoiceScreen extends ConsumerStatefulWidget { @@ -30,46 +29,55 @@ class _AddLightningInvoiceScreenState return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar(title: 'Add Lightning Invoice'), - body: CustomCard( - padding: const EdgeInsets.all(16), - child: Material( - color: AppTheme.dark2, - child: Padding( - padding: const EdgeInsets.all(16), - child: AddLightningInvoiceWidget( - controller: invoiceController, - onSubmit: () async { - final invoice = invoiceController.text.trim(); - if (invoice.isNotEmpty) { + body: Column( + children: [ + Expanded( + child: Container( + margin: const EdgeInsets.all(16), + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.dark2, + borderRadius: BorderRadius.circular(20), + ), + child: AddLightningInvoiceWidget( + controller: invoiceController, + onSubmit: () async { + final invoice = invoiceController.text.trim(); + if (invoice.isNotEmpty) { + final orderNotifier = ref + .read(orderNotifierProvider(widget.orderId).notifier); + try { + await orderNotifier.sendInvoice( + widget.orderId, invoice, amount); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('Failed to update invoice: ${e.toString()}'), + ), + ); + } + } + }, + onCancel: () async { final orderNotifier = ref.read(orderNotifierProvider(widget.orderId).notifier); try { - await orderNotifier.sendInvoice(widget.orderId, invoice, amount); + await orderNotifier.cancelOrder(); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: - Text('Failed to update invoice: ${e.toString()}'), + Text('Failed to cancel order: ${e.toString()}'), ), ); } - } - }, - onCancel: () async { - final orderNotifier = ref.read(orderNotifierProvider(widget.orderId).notifier); - try { - await orderNotifier.cancelOrder(); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to cancel order: ${e.toString()}'), - ), - ); - } - }, amount: amount!, + }, + amount: amount!, + ), ), ), - ), + ], ), ); } From 12ffd007ccdeb90fc710f87d82fb53fcb0fbe8b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Sat, 22 Feb 2025 11:36:37 -0300 Subject: [PATCH 063/149] Improve images --- assets/images/logo.png | Bin 16769 -> 17681 bytes assets/images/mostro-icons.png | Bin 52629 -> 41408 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/images/logo.png b/assets/images/logo.png index d2e232d50c989491fcd45b1a7e82a8b1b751bbf2..ff81f88d3bdab6c7512b1da32b588e97f30171a1 100644 GIT binary patch delta 14437 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 delta 13518 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 R);A#FxPsBURH1Ga_8)cJ5MuxU literal 52629 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 z z%C8D!A!qCm=>slo4qZ z{LirB4=UDKXm866tI#7Q7+^Mx-t}oQ()R8RPc`6jR+cOa#F;ltvndkNW{UC#C!Sfi zSY4fK7>Y^LtHyzing?7XM2hVc(_*q(g`xCmL@Wk$M`0A_a6_=YM&8l=c6lnv?jEVH z^06}nM%ksQWfg@vZHI0VE?YI#sx8bz&W#15Wb~LC-kRzcQmu?Zhxy5785Yp#2%^SA zcQ@t)4*EKCJ*kW^0g6SGld#~tbf$hjEZ1Qpwu=kD=kWN7Gi`KC+Wwo1JcTa0q5_T* z0JHsaCixZi7ay-E?!4w9I*MxW{C5zP|sgQ*+cC&g6@+44soTN7zkSD>6t4 ztxQ$OcX-`kNo-M(W~X4?%bwX%;r8RydKREIt9NA@{aPmFLeuS-m(_gN{Xbxh+=?_112NU*6pL2e(}o> zjm;~+f$;XMVKG5e+B;6OxaS)vpU|NOERXA#dfcF2PODubczM8c&E;Y78F&7ne4&~r zRZWM5(dub(gmU4xCBdrfH9x0jT-fVCl(N%ovMa?5N zT_S(D^gG_d}a&zY|RqF13Xm^4AC zU0Qfy!N>|=nq9S!C`af-L*#bT;kY^Vc*SI|HB8_`G`rM>Dr%cZ)Qg7|rZ%Wdojc{+ z4K_CxO%EZzt%u|PJzWv2eAABR4tPD{fP_HH0Bj#z4oxMn5+Hi z<3VsF@>aqYM^HoL@AD`UskM!`)~^?IC&mHW-vqILkTJfAPx2cr`=-VL&E~=K5N`li zRXrYqOeoe6|2W<3seaqosPpjmJLF3l-#uV2?VkRFn?>0xcgukw_yl!iMxHrDEnzm5QU1sBB7ZE5`^ zBc(A(YgH({Rjnj;X#^S|iRu-qAtdmV#+%K-=wD#m#X^qn2nK{VZM_|MO2_$^G6tjo z7kC5jmc}%;=^4l%BiC^}|5=VLqfULT}+=i)4b{R7Dk^oA48-8KGFbVNI#Bp<~HEgYmNEdP2#qlmb2zQr4&1z z_-fOJhj7B9*42b6*lRRZR8gl{acmSgDtF~{nv@dlGXE+;r>dSIgf6(Fwgpk;WqhAR z(AlF>+(c;GSCx9uHvJAoD`{QlpB~EB~NMmx2YP_VGJsPvk5u{=F z{!>G4H7J93UNmcXP9IP<3|f{|{y}bP35Jf&PQ4p!?l!j(=BogeSZ}upf3->>xcL>d zyid1i!J&s#Lp`4pyJIG7E3V@O`NlwT>aj`2&6CEnW0)-|MMkRQ*B#QEv^8ZVUPBjv zF&Xb^X@vS?o-KZ#(yC*YFAg26;%attu+D@=)-(Kg5}QMA1p(yj+1*ahdpglwDs?_d zWuPcqJH4-{J9UlCBcWA8(NfPQORaTZkuVS@=a1iU5)ohu-4fbwm(NQGmulSsI&SQ; zms=T84;@Ao9ZK%=V6_=%#>Mn!@AscRAoe43`osvgwozxR5?Gq34bB|QWoR|!<0iP) zL3&SnbDBLEP)|ZzOF@W;hU1)u!#5Q2z6)d6e@mJ2CwuVenh$(1?BtY%t>VlS9b(rb zc2K$D-9C;jOUWGr;YnEyqQ;By5#;?&MWUF2_o%!q_5e=7eLx#WX2;6YItOj+CP#9& zq2FPxs^6wB>#Fj?>}t2IepQ0nktN3ik78KCq=K+`K)TWvHOU5vj!Lm?_~za zX9*Diw^ieXb9=U;s*ng`B2ocSsE_>W&|y#+O{D#~1yk$eN{2L7=BXK-Y2fu&gKWR) zp7J_6ck5iZgaj(aR=UtD2WR64{mIALHpxsekMA`NuQkd2j~2LJR(ZUPk8NSaKNZfg z?TvgWxO`?s-@loj9FB^hHoAyKEi0Z@0R3q>`(YKrMANr34W-!AYR102Iz!eAe9kml z>2w#;K*su>CSdF?F}*3)I<(umg5n|dgk-F!KkbAyfV%=o>~2g>ID2* zuJ(-Je1RaHxf5)}5aKokN4;6Du6J1P4c|1E_$Stg_jPE$3tT(i(`)Z=m4-6Z_~7tm z+Vj+Fcck-!eK_N1Gj0+ec(>u`D8RT}(u#5RPnpa|QECy-iFVVRfY%8DvPV3zLKDur zf0nv5CIhmupO;6pK1cYB-Jc^5`p8|D#|&Urs6;3K2vIQg+yDBUFHHukr(P;(!?E#e z@ZvTl-Xu*-f<5;4Qg`||XzMcBloKC;gN>Zm$?;|Qvbw&%(sP56>wd}oN`YFPF#Vl5 z8dIGb8<9Rjap{6dC6sFsuS-)d$-K+cB!7r*yKK{o|p zDrUAIJa-^`2l-QsoDpCJ9+2$wM=~$DaRd%?P1b$v6;YLeeu&@m0d1qAprj^oVclC; z?FtRd^}^;Bg#v=CKSB*?$8xv}2K)o7qU!A|TK9W8v^^;8>7B1Tj8=ZVWecV!i~MZWmK-Ph z9qK-iFXhVrydC;IT;SynfJ(B&VtMe_4(iO-fX95@Rjt(11R9D02qDXL5%K~_fVR{s zXG6L7)L_duSw-~&y^3D-s;98rAbhA1dMGR&tQkaA%p9cElG&fJF-9D$^KdWE1()ct zKW%Ac$6;$>e$!Qn|HOMM09|vRXtk`onww|jIFAt}OTzs&R7tH?dpfMWY?#&Ya^u?e zi#O2n*V&wG^@M1{9P^0Ot{rHz$~D3M{qMW@%kwgy&kXNQ9sT#z>oasB2p&E_Jz*wc zoT(LmDZ=`Q5br3M5r@uhcH36^MA8B`e*f%3f<(Aj@3t{$Xf)*ufAt+anXZ=c$&0j^ z91+h$q>4wvH8BY?|CeB|HA43lAw?jN4*qa9!zLF+^O|rG~RN+ZtEyIU8q<*C~?Zv`g$Cxv#b`nZ8N_ z!j^WALtdiZzgM3H>nmBaHJnUC;{^g%qrvfsikYlOzl`vt;nS-pn4q-{9~ddD`I{>V zoon>|{Kf;ZVVz<9Ik88f0GU6hdtB0U%LIGW)wCwu_^>OLMl<1ND;n)D7GZ^-8!jLO zRDnJjv5~B4AzzE;d8Xk^_tFqf(n%1oRl8w}|Kxc~UZ{Uj8t5qQ_V^~Etf&xXiwN&B z4pwh_h$caG0-MTz>B&M0@hXX-a%L-pB=GR zj)Ce&7?|wDnhu)vXvKuz_{t8}OxY0drjg~}VX+{^NEI+V8x ztzFsy55m-QFIQ8|zk9F%?zR69Q{NmO*Y|xr6RWWsw{c_JXl&bPY_qW&8;#xAR%6?C z(%A1LpYQK^-anH&bMHO-?7hy~`<%7MK6^&Rp#=D48&4*>r64orcEHm)uFWcAYI%Ng zY)oT3T0y?;MAhgD;uO(D?FwNVHtQBTc3pR23kh(xjC;mo%U#S)M_9J9kot!()(v8&cUzVQQea!D~Qwry@)%0Wo!vyPB^I$_iP zB$B$HEYV?XlIZ+hUs4uJC$S&Bi&<4Jf;XKTG{{lM`|1qU?V=S1)J1HC%;)Tf6wof&A`xo`z9A2NK8%0 z^5{WDtq6oVC}cVt{ENoWygrx(yuWl3y=@s*oBsGI+~^1qeW{2Hmr;K?%{UOUTe3BL z%>CX^;cQf>KR!r@;>61jS4@7Q4ojZUg-+-IMIf{`sadYvvT3)T0l1i>Kv7L5@ z!976p3ZR&8GS*kyto!-l0k#sIakm z?{R7~BhcBwbLy-d-{j>h9DD_^{8=G=^o0E+6VL{wO2sw-00;$HjL@@&5QaLUPv1=x z7>?n^ddapOM~5Jc%7B_5$o{X2+LG&C%g^d%`qR*l8wt6e_y`p0SF$M{^C>j@gnIGAfw?Q5kQ8H z6RD>3`UDZjk%n%I7J(2(qHGZC3*+}iDukX`p-{f@vVPxG`}Q})mW}JT{v5~O@yu3t zQc&Wd$k*D8bWTCtrm-WGryd7l?k#(T9%0X$GjM&vs^Vt3xRTl*C!kS$KvC!Kb~763 z^XIXo3lMv|_|wsLItcMbUQ~E&t*4m0d=>J9KFAdTSSU?81kJO+d|w)?>#a*Qht&xZ z96aiE5d520d@Bdx z`!Q(cOz(MS;O|F%wmRig8^1j5x(sg`nZZM*`dlO%}bobad6qu`C0zTkAutt6(`MGvkLuFC(4Z%5%WQ z>Q9%DMG4@VOacyhVl5}7W)6`m`pO3dnR(^Te2=} z_Y+^`_UB$P(OSEhT~(+2hI$-Ysd2jaM`A5F+8@IBzyS}nr)QwuVGj1H(+N=RWR2jDRs1|I+!_f8Fo^<-|`WTp(DH^Yf zyE#_A88iR3P=lu{ z^@#V49@=bx<1i&Q%byP}#g@83A_GIi7s#+tHJXQV6Ib?wk%I5?#7Ap>yy^VozG95* z2v+9~>#G%n2(Tr4xQZRCzZeUH@8oXt@pDW;qgu+5;EzuU4n<~_#=D{Gy2$)=bxe9n zp`|5w$%dtE1IO@nHV)a5s{+(e!4^~NA>oxYytbec?TUN{y_51j%nk`HIi;P;+x*dm z^q;C13H=M|B(sAq@y+DN^P!q&jjYGp`I{<1<(4Z_$9HAw{Xx~glCdVKCJ4Rmjk<@w z9eC^&=TM3^q3!m`Aw9y_ndUrw4_}|-_?Xja-rPZa(R>PdTx66pa+wSM0PqhK4`r6g z+DbKl#tqKaG=iu=&|{Q;k= zwMM+Usj$vwcg_eBPDONEmFOySUaf-(WB0~;1q89HPkbCAN-Qw^h-^08IDgsZ)04LJ z5q|~ggOLP*FdT#O1Vk^nLp`J>q7+Um)5KHC0~!Ij?bwy8iW@NpLVQ!prt-W6&-A$y zjV%t6_xf(AaG)LmVHLDiqqBssh9=Lj@;H_XBTU2xtjrO=kGJW@+Ajn6FnR`7>}+i{ zRg1$#3LPLM7g@S=!0C0bbMc%2@Wn6lpw#74jpq$a0<|`Ke zEVPN84guaw052MFl%HO`6}uc_N>Y1z>==)bod6ds>0hwq<~q;%&uk4Y`AlyR5HVKm zQIqp_8@;jssdwXdFaz{_VfXc>OhHgAT70DnwfC=2B%61dnJ}$92Sie=f>Ubpd8uds z@T@)v2d4;}N_|L!;u6@xU3fQ*bkp-9zQeO*BZkf95l;3RtvV$LzCm5rpWiPH+YA5gd2tDPA~>1v zaflu7-Ef{NdU>Zv8((lYH&cWmQ<_S23W#XMCm1Jrl+`cU9K6wEw_+2!c2~2D3iJos z(lUNl-y!`thH`aLm!Tu(OnZW5qNCu!LYgzGzNFdALk@%tRH6C&@2GIuo1x_4TyFyz zFl6cgYIZ}IQXtat@2{!Ib3z)l;HEeTn_|o_Dw+-;Q;+hO^phR;KzZ657W9orn3jk# zEPoIG+Gba#01vzFCol}uJQSue;)d=8q)!K@5NA~L^~n(zKj!V;GB(@CXkLiXT7et9 zmAAGIRL?N(`*XHMGkyJ#`n+-pd*Lj)UXMZ}&lG-hgOp$Z?{F!@`NeUY7eBo4zls%% zSi&K`O?0{y33*UNaKn?hJk{PZ{;EIYy@4e@_YHr0*KJAqxmN9`K_V3uv>DxsO;ae6 zySkf7nj7~PP}my~I*+(1(W70WO_G26QERbMq{{0s(2*Qk%<4-JF;#FtL8_2-x>iFJC#e>Q{PpqQft=%3O3Q z@TA2c#SB++{aoV90uUXr$y_4(8%0RG{bSmbO4EvsaS)w;9! zUUsoq@qZ21HoJlF?a^~WGI2r2D6m@{qPYrY19Yx=Jf0V&qiTwd)a8vaS9nFTvkG{W z!A2_fFPvulN-fBquAEOuyt;CY|c^P-whuF3p2|kNdPPNT$ldIBu^USU59aa>muJJ#Y`E0dsQ; zP3(Omx-9BX{Y~RG%|uCi&tGTV46wx!1H3O?4`}J3vCUXufm=Ir_-MICM7ic6$&pJJ zYs;D3T-$Uvn~y2qL<9BXRrnD=5T!l=pDK{=P)hbhpcy!|HY$6@b)jTN19N4aoC2!+ zy6wMcuC^qD8VCU${-AClg$xj7Fo62;=si5_77FbHpLgF?z2~)+yA8}!&Pv5P=XAVzQ4wgPoetr|> zfW2`h|93%`OeOJfCNYnmlmcHPs?dt3Fx*#o5axu%p-OnMc^}R+6u#K_U{AdI`nCX= zM}O(w!-Bn_fk(GL_V=F~5`HH~(%+j$leZP^J4nu=R@R_lA{ljS_Wlh@noHwnueR*Y z^#ybzghJhMQ;|6QAkNlWX1Qz>QG75^YH<_FbZdw}wL?>C2%A(WTZj=^t-Cr?>zU+w z;NF1#6ecom=?@2TY<8LKbF>4({KPD|Ze4UB7EA0rh6OgvlNTu}UGM47@e%i-YeENh zRV52q3cUA~_~E#?!$L%_xzQm?ur~qI2SK=(gBtXl(u+c-OUq8muWwIv$FGlvX%taN zv4mWm3ov?=e@}=^V!m&LBRs-J^rDqb@^#$K?In>ek&gfc3Tgk+0W?{&BD8p`c^NhJ z<4c}YW7ye26fB9^MY<=}vxXZPP7K7oTu_S$jlU%+6!}IDR8uE~l-J%%THLz*C_;hA z34+XyoPWKDs>Sx)X%>st6_{S~5O7TD-?Ngc#qEp);7UA!rx-$jIuP}gO@{Lu=>6s6 zc%Ccdy}%4nqh--Yv`g&PqZ0pyEKg|iU{663 zppl%7lE?9o-Cr~W044$5;X9W{o^c;+G08HF!TAVHy$`pr&KkcZ#mmXOgBLjS&f^qq zwr0y>~ z#x_MzZ>je-Svl^u)L17>sTp7i)RwGsql9-BNHj>RG} zWn-I4@hD-FRx#8jEudVqZ4YO5rG>N=I?&dwmoRwSZf&B8G`*qn3Pg&nb2)nn0lfw> zIl3PmyaZ=sOIN}}@%ze3+@`OdP@yWNQv`#`{ljws!N9pBkR@apw&$ij-0>3u+M}Axh?c2YslWbSo902al6@ZvMKA?!P!L&1%LU+2Dy2FFC7 zNqtNbSs$T^jyRr=1J3Rzey;^XInHli@KfOFU&sI^Q8Qw4bG>|W39T0n3hAzxK4e+-tI{w}A&7SfM-|IT(r$G|>*fEid(Bq^>KAPnZZd_WL%D;m(rj7jq z^D(lp>~?xPk?g2H;nM;_wa{2%=m3)L6zXK<;-u*=wlz7?gw%#xIEm5wz{U8u1PEE z{#E?`F!nGI`fIPA)DlK0@=2W+_gs5(N%WST%Ee zSGKyoU`JKBSO54PJ)O>CQ;Tn-(C>Xlex=fT!~Uuy{_i+iCSNu*BHhbPRR;7jMPC@{ zz$l-;{Rb0-69_+{%x9FTP}C4FlU7V_(J4}qmQCM2t?*78+J8vA4iNUVXXvOa-rKai zBcXnKYa@fhq%s_e+L$#a**@+oP1&;~@j&k|&Iy$_^z|J0fY1v!5?(vUohBR~e0Mhj z$sh>m9>m@|t{j;g-BQK%@De(oE^4{Hs=g~OlMxe2gK0qEzw|ioZXN#JRN~&rEu#w* za-GT9)+5s)x;X{}fD?U>5EYd9l^!o+< zS$CC6h#t~RqjBLAwM^_re3+mc5i#UAC8M=|zGB}p<#_=~fYXwrMnryf@C_SvV$Y7l}x!%c+U~TdHwUoqBtx>X}-VJ1?2() zkz+D~s(${+a4byH7`u6vcN%Ltds^97ayrN_d3t;ZRZ`93bUNZC=U8t}Tu)fa=3g`3 zsOBLvaeT=Bq`Mg3lBi*KbwSUmiJkNy9U!Ne{GhQh<@#3)FwLnnZ$0QF6aQ&c z3F26p+P%GyI_JOpiw5LD;jyunj6-WQ9GztL;?@~pO5RYhFVsm_l!6`r=S!7P5_O|e z;5+jGef;I3PL;0XPO-sz~%WZ~!hJJ@ESI1-YUHf2|HEqudD4xIG#r)LmNe@-j!x64)bJ>vaF59?e5Y z@d*s{-%ntVfNKT^8{+^Sl53QSW2{S5w8sUZENAM{kCDI`@W8O|4CWy))g6o)|S8W|&GS>SlTs1hN$*)7$weF9q z^AEOd5oaNHQaC{ZD3_m#iCalYwC$L-F4*Z#a@1tu_D?ukKGcjwtujEK?(e@-@D{KX0;&m$PF%}XkRF} z!F%@bvkva?s_vcM8C>QT{={~^An;CFqzZ8(28YAMw-pYXcV-p7fZ*RVfkodaTp(2f zShmFM0*T|?Y$Rvm5`Na_Q!KVU6;!iw9RRdboWlOA_u=cxuHvUMYKL;wKeE&eFrIGP zO~;<=Z)+^)hli8L0w3>yE2o1+_6r~qKYnMsZmJq#6Z^U(=8*B4-H_NteX~NigCSH) z`y#`im2zb61XcMpTE+YQHr(U74!i2JEjh|1#rTqP#Y&&@>3C+^^m7aKkSC7OD#v~2 z&OhUHP-F%25_FS$P3&@BZkPKbxmT*UZw~g>t;HWxJzQ_qHeaH+x1s1b8#fgERGwDF z1u7Nep6;;#rHLS}85;)-uOsFPcJ*$4N;g$t_8apa{F}-aTvr?Vl6K!c5w2aSk5y zQNn44$3lf-(1%O<6{$HHmgv><%>#IT4AHya?%J)V!j^f#^@rI(?cWDDa=0BB8ApXq zHTPcqp`71k+ApAab5pBK3VwGb1(v`H?$W|L$H7nRqCU^`aM{lJ9CJX9Yz^+%~DoWQ5AC-{ST zE~8(aSat6BX;r_U>WuL=*Jj$-i)>_Ge=4{VPH@!y&x;jiGgR6dOnK-Z4yjX?NGEm? zq)+F5O|`zYw=y)?H_IsLXE!9z?;(A-yuxf*m1WXB9_S-%J6cZ8e@}MJ@m{{{S+aoa z@PS+CO}U-`>dRTK9c0}ssLN@WOvXyh1O=?(|1O)ud-_SkGwn(QsFldK_bX}yfEyQ> zUPt?_`EggwlnUjSJZol4f-vv`(p($Ag%v|Q*h8iK;Lthq+?@TkT+qksCm7xIOr-?I z_bMuKFE?o)3s;l)6Ktir8KuqWshG!K-nS{s9Q|P@plUkiiYMBi5LMgAd7K* zMllCqdIjnv_axdouj}PJ{>k0A=p7P%C}Bye6> z@$-BTu5Y7@c0uq9f>&v~g)c}K9IX$<1zB?(l}U%#FNW6`@GCs({Rba$nC~u`t%@zI zS`e5&xW#Nz8Qv+0ajQgY40m?6o_k*Y`_MVTY&GlSUb8jrRh07yG`Yiuj>G|^yANfx z@xB#PM4UW;bXbaL(AQwMZdA#HTe!DmWhxl~It>#4H^4u2x071rNSnWS4JkLAz*a=~ z$-%WRfUcJmnf{p;jw9OyNbIZq?ZxZ=dlK}s)nBIvp<3c**cz}*i;;x**9>dJ^)R>c zC&glm*g}xmBP*Z53N8<5W_t%SKo=+ZkacsMd_KmcOz1NtaW<1K!Q%X!WE+W6o+%oh z{eRc^n*X#EMsqi-s0y;^*r)Kx79J3SxBYB=eEC>Olrs*GYRB1a5#i+&ALKD+lI#cn#h*n~a0`_H+YDn?-5*Z`XE zHz8GhoWE){M}5h=QKdVd1bM{!%*-}aoSYpF?=)!=%dub@^kZ^8);TLygfrX>v?mv(Q3V7=|2e%$N#IIEK73OqBl50J9g!jYlV+R)sy=DuOKHDU3Yrk})UwM0Kn2 zI8^^L!S?Y{jYox2joB=P{-F39rkBkw^Zf+pk@SDij}4LObdGD%j_Ql}M^!n&>TL@} z*8CWYq}_WqjDST^^#f#z3oTWywR8us7@^#qSd_m1In^zATwsH8zl%O7V*77V>V6D9 z%UsKKhAEwT-K;;=4JmNE5@Q%&wQTlaW~}%ywIU&yD$RP8)G?Sc?f0joVuF`xd7F>H zvz$8KsNYpwNg!OWb3!_KmYn&lYq4kCNHVVX^Ksr)svz{u!S?vM3@u z-|LyzX=^o&tO)$DXfYW!L|STfssKkMObM9+!nIC~XHRT1>VJNlDvbQY`i8f-SbtXd zd!g3{s(~on?@^MH%Z}pwLWYnsLn-pj%OzgUbW__xFQU!ZSG4ayAfqs7MZ% zbs6j-)1|4$lWOcRV4@v}6RHVbAQ8JV;k-%#m|zYl452??2t;@RpnwR-`h3_PA@@$m zI!66h!i%R$gB>a(iO$-t`|2K{Ax<|KpirHKIEMdhubqNmuLG3@G%KzMDS>*NHYfZv zelCTi^XR(2rf`UjbZxzUCu_K62+JPi+e4}(2WN)N?y2Oh1tr*opYsoWv9eEcm?5IKCY5ynED87J4lsqSR z_7$urn6J8ZRp~1`lpDt1FYtiV(`i4{Cm{FeMoSvZXEBKs8mtwsM@#qHcXXKrHS7wa zo!>)ICW?+}0pY^kp#*b~w|$qP7;D!XMdyA^uwobcZv_2xFnj1H&losNbA70g-2LwP zMiT$1{m#~K-+^RX%Q?DL=krAu?hcdNtPKADI}0$1t2;(Gb1fbzANLB2sokaO;GmwuU^Y4x7Vu=qW0^{*(54{%(x;w3u}IUbcrSusp-#q1k{Amq`J zO1PfGOWYTXpjO@^z3gv|9?o;N3PB6P8*V|Y1w0S0P<1qIS*fklSuo1<@ zqj&5`?FaJK#Flu6`M8>c%589k56_J|joqH+L+zlS)*9?xvR;o5mz2xia~?A7QnnpV z(}oFi@6e>~D|_Gde7S`|Hq7?hqg@_LN75sqwDEl5NNF`j>~sVU4$Z65dDk2J@m-Mf+U7&!4e-waNKs?@#+>VSBkh!$^e_KDd<4FY^ydyIR>*0}@{L`z7H+r^YiFymGq3k!nB!!S*iYY^pCqEZex zlI-Ae3FX@vA3`040^FZK2xU!vC*brFB=<}7Z&I9|$ZN|nJ91&XiDa@i&*FFs8TOp8 zFyQ3%&f7P-h>Pi+0j_Q;`FH`ikpe(YxTb`Mw_)tuaaSKmjYn( ze-L^_1pp-A5qie-{coBM^~WX>xJ)pux*K`cfF(8m(CO-_b z7n@^4-zo{q&D2;!-jxQcXa`d(K))2dn2?5OJi9^5=v!g(qOjbKk0R;piOA?9cabjT>QNhDGYk|oOA8_gMrZ2&na$Ec$p{x z95Ud4;>jWdLpIC?+99K-TRf2}fTXYxrOuFFiuHQ~`Oym8=oq{>UGBKvgx;xtaY4)+ zZ|d}+1`~J3lZ4{+Fl6%byCeJq0raZP(B0I!Y1EJP6!F)S`vR+ z_CJaP%AItG&W!)qIo~=R`K21NzOP1mlxaZ(pa7CWY(Lu_uV*^UFE{)*x!~P8`pDYq z-5dyxlo$92&pDNH8^)=R8QA4IqieApYwpG4_by z&G&vA;%~Q%B~;!xp7};?YUj>gRN$C)4=WjumF8*N)&@@S#H64e9@Wrdhyoh+fI@Gg z3z0+YU<>d|iqF}Z`kd_-r1xb*dG&)=NN&tFqdHMYzsTS{Yd;=Td&he7XEPbbwqai| zgcP9;Z3UFg*Z7S<`jbQsvtTo;dW~#y5yY>;#~lHO9-I0@@(;Q21?%N4zXQ>xzNFvq zx~`oTcFc&+ClvbBfdXdr6k4im67cBaIGMyr&h{f4`kX}l z?IR4IhjVDt$o1>DW6zB4cn_d;zRh5I9Q&2Z#C2>iCE~;mc_mlkI;{I1pMUZk#U~Pl z$MDH(a485fRs9Jr*OG*D$hlUUz$al}`#d@@B)5%q%uvb|VFpz?LX4zGoR}g|z+{X* z>D8nMvZHDD75<#>KMz9`?1l5af%Zv##JBOOfH@fQ@aW8d%T; zWloO=p&C$k8%a+ZKI5KD@)|(0WYhN6J?iwy@1qpPo6v`<9q8U-cu{BZz~~J!?J}lI zj1L|7IZWpO;IlyG+Ej zmEnV+k^2}6AM2S?9y0MCNBLPl&{S4D zK0+?bZ0B}F`b|q!!TN?9e8PkJ9fPw7uhjzIj+mtrKh%iZr9SluXvwb9@%Gci^JxR((^C2jbISP39$#}5>^4q~M&MPRq ziBIHtyvWMgXp}@IVCfkZGNtR1%R#8kndLWofz1M{19ib7y{|MK%{wmV${){5O%AAo zsf}IuG8J+QR?Y6{H!{QlO>6tuO)s05|JdastjtIpg+p0&`7gLIlRm}-Z z2~ZY(m0i4kImJfndE)qY0!bjGOV@%bku+Y8(Y6``h@d;3>l`yuONCN`56|TnD)`pk zOKSiPlWMN`tIDDniCM7`LY0$^!5|eT0kygBSP@r#` zB57g)wvI6s8toG47-jCbo^ zul3Dl2=edNbSea87Pv2^;aq_Q-UE=Yw4VQ}1n1r`m}n$|knhI7qckF~{a%IV_@=w# zaM`~hF+e|JPGURX(e(M-;KTYtb3El>pKM$`{Q2bwI`g{(Evh$O1pBDPJz~r`9 zBHG2yi;5_^BgdRN82g1@f6{x*31esvuE$-(y6@JT@?k;V(u)W_BtL-FUxpW2B{dbz z0&HmZt6<@7_=B!>;|?4AJCG+xh!^_Kba4ziG*omil=ePLo9JK)KyX;j`%Vf)FB7GV z;52~Eb*FNp*4XTh>!X2-u~>`SAZ((Gho!2rzjBlr0v9a>^Qwxfr>F9+h@@2)~fK2-K(#9mFd8TqCIXxW)?xBH2K+8%fQYx%8c|(ym^|k#`53UV(;=!< z6bNE&n=j+YlU#-Qz!J`z^qVp43{3;+OLn{zDFSXX4!_n*}jC}Af$`K#2@3!V7fRNV1+ek3hZ z6zH*F8(+m0I}9slllYcW#10F2b5>adx^aG%JZF{7m%?EcS!rqJ+A*fxk`h11VxR=R zIP4c&5y8xdA6ji1bWh~P_8iTYl1B``XFJL~64Ke^YVSc(-ga@x2XT?YSQ#2m8|^$L z`V;jlHw3e=l8~V|1ce?W@lO)KuTOB*INko(J>u&5Ds&Lpdb&p4pJELDN?rCxs2Rd6 z9B8pdW&BRbxlcz#5t-ROQUi5ED0E{c8%Hx47NXBJQjY*7v29`4Gl}4*N}Z382gSmI=c1hojm| zA(Z?O?v@d;7NcZ)xMyWN{LbJR)|NPG2o3DLeNtvMuxw0Y*OKr;FGA* zer7V#k2@pwa_kv-lX^7rRoPa@p&( zGLe^QZQe}fE%p69!-S2il{!*n(ySg8BV|PJJNRu@$d7{kcr^ zc~YE9k<>!`M?rksx2kI&+V0Ks6nyJtH?7Zzn!KMYd8&}cU*=^HPQPIfn35Ew*9ofR zXWJbiBs0x3fGNtbvyq&7H)c^jfAO_DN0>ve0_A{}X7O`x(%*`FE7d@oU4_&P&dq1t zR)o+8FQROv+S3Cev!ma8Okg3a9f?IMV}7xUGOdDDO(i10(^C72q08>Z^{50vydw8S zB<~tdpHJ{&MwCDy8ASTI_WT9CTV#*wNK{5%0S?Z$CRf_Ex&nCk@zkGE;YnNO2i9jr zqwKhtxFk9agM0f#xzQ_LuY1w&9KYl+zswyVlkU2zJnUgT=Y-AWbqRuIFC;d`4V+(W z3yiLqhVw?FY11`GC@Z~PQp~i-vqrI75T&+!-=c_%bRDElv)LQ};&#LItIa$xY)uQ4 zQ7eFv#f@fV)C2_tl*~XWV6y*{_0AfSEXo zKjji*38mC>%sv+@T^)pckF#>W0Zow3(f&5`o@C;O`qIuBWBXi#yNA0;zeMHpK5u3j z-2+}>JJUBz2nxW!?ZiT^5QZU&_I8YOE| z_+2BNJ|J0;K5s%!V}^^9*Tg`hF)oO#2_qJT<`Y`&E(Puql2w0MUpY%g3yxT=UOx&A z`y+VpMrRcFEw>$D`Ft^;O%_32?8_YFQ1OPwJP*5t10RI%J|4FD8>cTHLFKXX2eTgA zaXQ460+`jfzed)rF?jyvO1T`Ws)*h;*Q%dt+58$q%dPWxBHe3Zs^&^@Ug)HtxfMQ_4$B`r$>kGD&Ny1xls()w@X# z)<&W_aA!N|U@k@>Xkamu3*S@N@m?O7!Y@C?9_qg#oi!e??SB-k%u!Pe%7J)q$~p}e zBI9?C`28}a)l1tXm5>wYQHnLk%Vo9J2OVOG``Ue55C(kxgRG13C=8+EqY^!)G8tZ$ z0Bw?xrsLJ_TDm|hgaMCVDaOB}NIcM^rs}I-IQ@naUhBw1g#o*D?&3&!Dyd>%Fs4vX z2!B!cGAE+unik_(NQRH{p+ZEK7Fvsl#ZL51g4)J;9Os0rw(UkO;MDRJX4Q=(6g#)! z7eLsD#G?DWDSN-+LhiB>cBTWg)n}sy_Wp1lQ_+h`Hl*wY#N+w+lrh&tv~h4c_Zg}# zO+9lnrVh$$qq}V?c^bDQAPnqE%=JL)gKxSRaHUN%GlSFcPGfs zR$RWD8E*0ac%M-_``}oyaD-a^jX65*Z>$%}0fkFZv2xfKUA+7_4jnc> z&ISzMUU7q*F{OP(2cBBiRQh$Y?M=5t55PJ z&X*VXzUQ%Krv>TV>-XJG=O4Lp0=6cIA0a(-GKPNA+LlsR^CceW1R88X3rD6U&*=Y_ zUYz6^{xJ(wXdE|ciMvLz5I-=Aj3P8KEtSHMTtY$VtrAL=dP<(TArbReWw$u@A&uVT z`ML10^mKv@<${?fV;Z*Hs-zq6$Foxca=6<0jM-+XJ@QJ)tv4I(@yd!2@Az2dJ>8#; z3=@fbjXZulYHVQmD0o4pKWHHvGo^8T)2qf6`r6nB@XfD-7s2n2(&$`RVA*a0rSeBi z_S^Ijd9Spq!SuMNm|ZCjymTVc_>-1RdwX!IFGK+`=X6`6R?r6fClk}AMAK4x4>`3^ zDN6*ZH7j6@f&~~)lqLKr$jfdFcfJ6k3_Qr_SduG`f$YdFDhmnGEF!lNo3UcxuB(7WHwNCm-lZp|ns8mjW?#53E z&g6E_ZLl33=2*|IZr2XcmIFgR^~foh!!c(Z{z0PP4(vCaL0?#+w573AKvXq9}7das(>D2P0UP-{;= zY4PkO#G2HhZpE86xb38Gd}YiWz^Dr60AvC!ij|w2b9)G6bM&v>lM0-y0|7ht$ui= z$4-0|$MpQhA0c(#YfIBe27vdbjkSz)Go!krz^m^7F%o{PEoFeB$Ly;Y^!mYGV6Xh%^xD<4Mtq`G=ni zARqxXkDFO((yq-DhBxQm{*;7etzGa=(F<^^&EQ-S^0(LeWpVyKLxK~0zOguS{QU!= zx&i)&cnJ`>MUPhM2^B&Tmmb;~fZEbxiqfqWIf_p?fg1f-z2E~@)>IH$^`I_5V({0% z0o=(;TA_%z)1YGT!By`sJ0rBYc`@&}HhtWTdZQDcfh*`I!6B ztTpE$8%(Nq{zIAbzQ^V(3K$4_nJ5J1`@f*i19-RTIo63Sc(Y4%oG#Padejz=&?ABQ z*gy3P?1sdVYKqVh-sFt*@VX{;{1J~{YigTER{fB9BqSRf2A z?6oA6gzVpbdz0b5rdwwCQ;8n1neXGozO-+;c`{c%LT8f4OYbTx!Y4i5R}UD$fh!7XR}ScK0pqJ9fA-+8TN;xlbEvPmxfR>6iT8rLU%v%FhuHT zB9Ke^s3)Rg(PUPmq&));)Gx4bWe5 zjnHZ{iq)gcGaG;5vF{J9^B^9C+z1{N8c+U)$}Cw$Ux>-#4^$0A=}i94k}}zVojn+W zp$tjnEIPw}<^&^lXFr9GaaAhI{x}v=VFxNhjFty1JlCmiVvX|9@D`%cWZ(dGVa;uN zgj4Ziq?EhHy=Y>LH}G2u#+q-Q2V|Ar!!MGsZVD=<8fcDx$6E2PZ6u4H;`k)cn9-LA zK*I<87jxfwiX~oMZH-KX9lnMV!C)Gbw*yg!J;@JLgW|WxtM@m&E_zy8;Mp1ngR%!TCL);4{GpNL-E;^sbAmQw#VksCpT$_q#c%k>% z4Ep%%)K>jsty;_e`5F9ZHaHD?zR(P&0)m+$SMg98td#%e?|rZBVc!I3yikeCB7QPc*;zB5kY3;AB%Yq zlb!S=Arv$tcDC`^M#8X$ilPe{dm`od)vS6$--#~A6Ry)I8Sq49w__+j3*FvCWwRU9 zL%MwXVc%Jju~(NppGosW7{_*>9V-LXh}u+CA!N&?K?MvNa*i)MtWm%{N`Ccs@aJV8Fp% zr1E~Ow7A^({g16GC$k7Xf3r5wtA@{-?&@Rv9D3)v%j z#NmHk4rE}OTzcDc$-FQUug|+^6Y_S*zK0NtD}LlXiuBm%;jJ$RQZ3bjS(%2 z4k~G>dJDq|d#V zG#qgdp;A3mi}%-i%j(2Th4e7hqpe`#+3Y@p^_(Uf&DFIQer@~?95!3W_!=`Ho-knG zm>P(Kio(1_At5SI8xb%s$}f&e()5oLHtm~!#02AT&t_q*;SiVUMituf1VaaG3R#Ky zDpvXelq2{F6XFTgR-{#c>*ni=tJg8nbM8Z^ail@xZLXy{D@+YLFS6}5qyxe|xG&Fm zl(6M6BDUd^=O8tBO#KWQOus-v#8r|fPnR7{^?Aler4d(=6LKee$9O&UQ=kQNmGpUd zToX`F9sz9n3#)B6KhED?W)xB5^^*u%LH{cNPGg|7&N6 zTZ@2W>GKi=sZ_V5oKbYoJU@4m70Ea5>Q4h1Oz<{bZQ!R4e^ewuO}3-y4F%AmI9ynx z#zy<2$f2rF_ggNP5(Vx>Iv$sX$2DNxpa9+n#iy>z>P&`?bzaln4ql2~tH0xNC5i z;4rwm6Wkqk!t>vA_5&<0=iI(j*R8Jlm31|Tdn4;9v^19&~r1AK-PLt_-WPFh7Q)n3NohCcIHEOLhuVrS>LhKb+nTnCB|)Gg)r+ z2Ne%q#z39h)M)ZD!cDqx@@G$p}?=Bx4;pTBl_n7&$^&g`mF|GXf;@*6CZ@C z?o_P1-@6Ch@DUKVc+n>53gdC0rjq|AUk~Hpx8FyUHavV-aode)!(TXszj`+W_g)Dg zES)t&yG9*PJ!fdAdz|6xNR03nCu_tWB*mL!gGoyU+CFD zBI1PCPe11m6Iv6FX#u^qe@(A(`O+k{&%!vv641jsTT{!YjhLb-E{fzBIlW2@xalRX z!%R^q08S%=>%AunyvNTO`zguJ9rr<*RQfN~HYvVxS~R0fXhk~K($%JD#hXGsP4 zF4@%Mv89XGTIfFB=k&B=it|b|zeGz^f!M>J848Ozg=^%2lWPiT7;zS;8u#Xhg`#Rm9=`}6zYP>VBo0C;68G^2wIlXhx% z++gaKWw1(M^eQd5+8Ji6fQ^@64{MehV@?G-%l_!^a)=`A^}mv}V%k?)+3EcE>zX;l zUTqMSud1eV*PD)P0$P7Z@#o!naHZZ^D~mlSdQp_a5*i70vL#ewI>Nzxf^sU>BASAl zOnI3Wj@ojX{vC$NPlV$P4MxFw9O;fyXxSv^Y#UWmnasl#Fq+Yle2WoA%lAO!a8t|! z*3rI2EcD^2;r(iB%7}LUSgOIpGj8h0k?5@}kU&$u39g^e8)S&MXkf> zgd>KW$U){4>c+#^vYuY6_J&5SN%xezRHgW6n=fq7 z8BM{)vG=3YpQO$|u{83}JstnZrNDpDGb!a{W-%P}i9XP0O*c*K<+CaM@-+9_dqNJ{ zeW>BzaF`Y*m`~C!XCbYd7-?yUHXK5~e|w&>2R+4tem&D{!xIdziV#IASZCc7-gQ<` z`douqj9#tod2biEyhDJ7uzTfLq*lnqP+Jo&%w%E{Zia5~aJ$Q!LhMwL07xYZD&r2pJO$N1IyzMEU+B4D>B3*FYMGIN1^$9yf>{qd6P11(}OZ zrC=`JpN?EJs!MFx8q7h{WkQf38q^byO(ZO3w`jmTPX98g4i*j#>;V?$8SID;pqhl0S`^mqn?LJr#7 z-Fo}MgZ?jcxJC6p>~5L9#88Up6d!iK$h9Km-He$KvEi6c!H#a%$Z9uvf?WZ_qxSHn z;ALc=v`_(;f-=410-e!&O-|_Bdk>pKpx{%-T5skmhEiE#nZ!M2$u0 zY6C>9%v<`{BN%TY>>V7eM`VdhscXARU0jHtu9Qfivogd91#(`zQ-nAB#SYEO`3cum z2esBG$K^{3Cp^y{=hohx%Oo}L9p8feUGEM@)m4B7Iq1qbf4|Gu7rKSvj1Hx3gBPu8 zvJ!b}GBhq>^sQH zN$4uy!>A8lQWQF~IuPG&C&NI`Y+uEhi%fuV&y=4UMQ7_UUWk7KmG5N{4EoGxC6zlN zTaDjw7VGz$AO96i=A476+yY<9pn_`bSN0Og{iCg0>XZ5OT)+wf`nST2Ss^Try}vfC ze+iYE#QEPxpRljQOm<+-Vk-4-yJ-)ig_@53QbzmME)mu=#?Hm~lb@JE=3{q$FTh_4 z*6&|4ib}%cF>x7&=;hk^YH9MV`BkOQxj_rOPcnbU23nnq{7ABNo*bPCDjki8cp3#Jg!6pPbCQT{8zCVsljH{=qZrGFxS zMCT?OQyAS9XM+4ekF!&yiFu?=^$<=3AK}gWPhg4$sOCkJT@U)|qgr$4^w0hXThByQ zoT8zVfw|V-kpH<*MC3Gz9OKy~`Re@;2Yt6_rrTGc_De?k1B$mX3=p|D z==-^Zw4ps;(a?~SFVvjR0nH-F9po(#IrVgfq3df{R8x_7P5b#Pwg6t={cqBrd%?sO z8{5xSxq*Ur-^q{7kkOGRyGPD%_qKo3zxnre_mKLHCxO)#JvyK1!zQ+49SNX50H@tA z;*9nSs}+>V%Pb%e;iF+a<$E5N$J@vlD@2N}TLQFaFP7Q0D(L)=HtJuA4UmtRa#-2x z_%9-1$-dkkacultK_MS=jL{?%x7KT?$2DK-q2>w+dNjad90tS6OU9v$Xca#r<-OO3 zXhZi`-pBoEzE1x0+%b$ZKnj1iCi4V3CTzU%1zI(BHPD6?MGei=Iw?$ z-+js;BpO9i>z?&?Hqg%|5Xe&?arRuFogO0cO;qUKZfqR_;!k4;w6&^{jA=-=+l>4$ zAL$Xjx8^NX>_G?#UoVz(;qCwOaa3QoKkwgz+w|dfiDzM0l=(<0v8B8O>ka_*t8*Z= zOv1i_Y(6wRz=Lb@Ss=J0M7hOmGaWSAQ+BUmV7#opcsYbX`ZT6+Ck94MMK_;xrYRZ5 zTq!DO8mStCMeZX|$g_C-3HHis0)pws>9n@bT=qt3n}l>M(y zf+eV#_6lf9OxP(7gOo_r0`$TL{z~jmO!8yQ`3gs0!xu496R!XpXXe8bRO$0TgM4z^ z13hYtC=sND;dU}1W_>qdd*o)&`-HhWuUFhV{~hcjc)0{j_c)Pb6oOpjgbEH|kLGYN z)^`QozAO>(R+WaSu$-u)rbvXc0CFoEJdBgdGZJvneiMmX2mF{z#Yw?lBhN{lu;z{T ze0~FN6cSNm2P3|at`=^e>9!jIaa#kJaTz380StCGwvPfEi2ba}etrj>SgWhyAD8g) z!tR9a^|M$yW6p+Zd)&^$#RR|a`3Rn`1D@iaY}~;X`W%hm{vMIF(EgK?{g|u>|c10nZ*_ z7Cj#j7>zPe6O}vI%u7j!j#({eBA|$?c z{p_oS63g)m3>#pJL>6E-@`6saL9WzwbFcpxY2@}xvR`*~f%ISgafaMgtrAO6$jGnP z@^KR31BPx0PMgsos{D2lxb!PUxG7ZXSOSkH`5-B^IWorkUd6m%MA2&Vo=k#B!~XWz zDv`U+_6$iumoFmqFC}E<9D*Z4Wr&eqYR1$Lvkn2Swmvv(hECpc)UbsPtq0qW~ zU?tc*vY_OC%9pG^%1ZU`@*xv{rgFPZH2?%nv7`k@zL<8U6odvAM6Juq(q!|F@K!bW zwN>Jt4J8D+o%{k`oqh<}`}6dacuiN5o<-kF%cO~rgR~Tb(3}%!&E=X`mfT{?>Q*kt ziqvCH?bU?7(zQ{CYrTG-`|z>&AH=4;JmF^oR;BFO3f)IjfNym#HUb9>wm#xMb;Zoa znzKhE2MpE#5yifa#c)x|8cRmTZ{$Ie%26LvQQi>;iXsIm-}3B@Gpc&IooI!0Y`kD* zu5$Wy$D)~r=oxQ^67)RE;-B2IP3v`co=~D5z0YB_c46yZAJiPC|1dnUIEawOl{s3a z8xG`2RTe4x?O`KN1v4dza87Sm)=K`sE_#Cp2g-V@zKqVPu=vz=$a%daI<)@nE+#B$ z=MZ;w-b6mhPh_|AA=hw2%v#DxKmQ4nP#iE9r2#}%5y0;DL#IMbR9D;gSY{IlMALSl zHzidfc|ZRbBKCXG9S=7Og4MssT%?vOdIF2`1Eu}I8!tN7NphG2fwsryEE34|hXM-$ z59p}xcQ2*;;=bpY>3`=OU!5c|;Lc3WN(5f~tbZ#hk(#)Q0I)y2gUz^I`t;%c&m4fI zc|pxu7Z8FbkZ<|Xuq<+}8()fbZ^na3%h`nciQ<<=r^jjxKk)M?sr5!!&pf|@uY5Tv zbI0GW#14j9AFhtqG;zPN9)? z4ljj_3s3bF?~TUTH57(Li>wBgyPjgHa_wqz*P-vS*!E4BjdgA|G;ECLY8tKd(`E4x zxvt&8Wj4C{wsLa-Xv0*hSl45oh@f-rQZU38@F}HlhQ5W**u=92D!@5->X?+Q@qU?Y zK!MgqWJzpoUwPZk%S?pA-PGO|`*@3`J4ou4P)<7PX!%uhDaTBdSI!Mq;IP(s8X;zl z$ZsGHCplKFvZ8G`_fDurV>ZuQ}^)1U?eo zGZ-!A3eknze<_7^y9_osIvVXK7t-|%J%fn@T%aSs<_ZvrGnSH?#t{6eg7vMNwv+-7 z$2rZqHM{+Af6RKlC~Ncm^_FbW`9s-UFh#6y*scop*s_rS;(E@q`g-N4&kxVekKHW^ zgH%No@MAd<3Fs@OMvvHn89!hfH4Ac8(gacD3v z!64~JjvDIjuRF2MP;>mty3Qsf8&VznPird8_`79{J=$m|rZjkL(6dBz#S09n&`gTQ z6oGm~7ySmvVXbQ-4&dQS*@7gFb6|u8x)EF-Fk(gDwO+o4S zXgrrca&w@*SycYc5oZ&D+M05EK5Tc~I}i(6KpNH!=x=v~!zEdZh~Y~uS?SHEG;+&^ z0aN`R9m4Mr*!eJ`1X*jRBXVNA%3HKM{=W@}U5Y)?ZB_h>ZSON6f2s%gnS zTIVQFQ?2|gydOTHJNHlWES0v|nhPRfohTq4GfMQSFn5=1vqJ*kCPSC1i4#WS`d-o5 z+R0te)^$>{JxrJ?jb>R74rC;>ahu+)^xpc_?I^n_vm1!1V-e)(TZIJeDN}7UY>G%% zo?_9u8;vJbm0b!uEFy?^iN}*`ql&X{l&!9RSDz>t3Vs;LFy3l^R~mDoYQtP=qWiyYN=ZQnx_Nq(^c#UD&ge>D)Jl+bXhQnAC5%c zGJ1qt1!;e*<)ERa`#^naA(3E?v|B9JVec*4eroi_ke;+yvxOL#XbP1+{eBXF23qd05ysa8O9+*|R7A#_Pwy_TlM_zgFUCY>dskyf_9q>I` z#>8z<1d-uxlGFAH-p~ZTe(!$z->1S$pTdwBw_ADdCEv&XFKqU-n07N#OM-X?Kn!ewV{wbKL(p;5wOgfn6^HQAlSA;1kQ`^ z6lQt-JU&6dQrD^j;mAAJ@3@1kyS9U*)2f54Z~X<+x*-1a5@NS`Z&pb5SJ=ajzaVd2 zydK(fHURrut>6^?W(JqL9!D-*^#X>Wy4JN}=)?+M45Ur5!bYQJBN9PfSuNbG2gF?u z7r^y{R|iOA<{OsFEwcJ6UNQEV@lLbWH2T%-^z}|ipMT0(nOnb$6AsbtnOH-#pDu*l zBn?)vj8<;;pa^a5PI8O^{awb$rN5e^t7 z`SHM1tZsbdLj&65M%xUQMq7{Glm3c-jw1NImC?(m$JI3;HIB^i$juP_u;c8**kdVd zN<$IB>HU=W*--%y`8qiY!v}NRFo8SZ_!Fb0KGxPgI=5VN@Lcx63jX}qr&v%CM>!+l zy&gG$yvJ#-OZ&fb6u&=k$k`$Mh3TMmt=Q#bZHH0s;nJklK0=L2&;sgQszAg-Z?8TL zz6os_XG*jbh6Nk6l_^^a@xf5`V~QxT*iyCGZ=>DxD986tm{lA7o@|`@V_4yINy8%7 z$Mn6)3H~H3QR{=zZ<407bV-k_g%jK55WnB&-08X&LuuP?3qQ{isB&r~emD5)fn(d@qmtUt0B%?%ko#P6CODh6UUyN4WZ^t+u`&g>K36s?y zej<*Htqt95^7XLO0tj7*IesRH#E9eyIWT3e@rMc7VJjcY8V~pE3fyj8A(^-{TIn2$iMaj>TTXiGqkIn@5xJVO6 zvL!{AquTURtloWG-*4px%k%k?NhvYVl?_?%Wia_HK{|vW{ZerV_V1v{D4nU7c0 zUr4+k3mT3!+y`qx(7Zf@V?8M1w16Oz{B5{Dz5J{Kl;+S`iD|$6#ia^2uYG{piMf6m zNNM)dODllJ%NZ!!*Bj;^GDkr=Q#}lx>jNr!EW$-&S(dFxCUGS$Q^v9yPk@EwMFylJ& zSnx^R4zt$0yRF^`ccA=`(kxGI`N%F=seyJ|1#*<;8SRQ;gas%8gVg_|8?D7O8SE5c zAd3DVDNeNg<|6(cQ$GM762HC_=7z7`O*>dK^q+X_MYp|wU_SC`i+x7}RQin80`VlQ z+Z}xwA9ZX{wv@k7mPB-xKIBE?=MB{*WJYqEw-i)w}RuLZVo8>@{ zG^yqiWvOUDbHigV#=*Xu$ zOCVb9wPM*tndkDOgRRmAAVd4T@7ksT&RWNsNMy_aQs(FNCi(oJ`)G=!^dSyp>R2N2 zhZbMxY&gdq{J^bT`xOoA{+g|MxbF2@IEU7|^ywz;k`J1NRDsr}zdUaYoomQLZ z2Wg1@TzR(vhb%p3ImC^zoF`E|f8OdQj}TyeqU=H(swfGi>ZOkx!&lBFD7*%L^0YxJ zcs&s7*CewqlSgHBKQfc+sUq|@xhm_zEcYFP61HS6wwoOPuFTMEm=b?ZMLNtLV~!^> z2Rp;$?2VPaB=Payw!^~)Th6Be-`8^O;WxapX_xb-*bJ(?o@GiLGU#ER4(NmbZJk-@ z`6sf1_W3qLA*wu}7%s_R`!f^;Oqlvtj3?LgaHhunEyq8Z2%zN_vfIj2j$9oa*+a|z zA8_91TYJ4+rCiMjz%fPj1WN^mT0hQ5x11t|KL!Gb=nGF?+8THG;||w#GVQke4OuTc z1mJ%Kn2WgZvjHjG*4hI80t+CzdFw zxN~iUq{KP1pNM2nhxY^A()~}9%HoY%kDi_F3`%sCJNnWeOQrdBiuF@5L#Ps&b&a0@ z0OH%v5@ITuFaZi-2~A7cFEN&-h*2?gGN++dV}=N1KRJR?(6JRe>7BJgJaF56;GBjA z%8H!wpP<{_tg6^OOF=!lJ%_Zyg}(@|oj9{)!@@D9UV9DN ze`BKT-*_pJJej<~$sd=@C`iOr01d@o3x;bB19nGLsyMER{fH!C2m|y1H)8M&f*|V| zxNZ;RR^e@w{~C+ANC67JQ1vO>b%{g@l4nb7?+)f%4f`2_(Ir0G$MyHy>PM3n2I{k44Ncp$4igp$ zWsp|vp@!5o4=5hFwEi9CGy(QG}&y|;jC9v09!!}nY zyE;3LFx-FDSaePg;X@a#$Gu->3#*ZC^jA&sq}oBq>rT zV{ZQeQTb?ZyUCr*QRnR$0Dj@$n>u0qtSojzPZGR%y;axl`6ur(&ap~M$p@um%N`@S z0GpCOR%0Q&{dnOLXP=;QtHws+-o>=dW}0re-$90Mg}R4I)spLaeMfM8GH3Bx^AZ=h z*n1$+hf*b6rt^+Q&7W3-9GkXn!@LG zZN=el<$pk}q;!Ky6k?H%&)496q1O#P#Qg#2^Q3MwEm{d)&?sm^Qe_ij4 zYnQ5>+&K>$Ix2(6bUTo!#M=pku-EYg>OV|~!h{SlgW6!`{=(oo-YG@x;-Mfx9^xy$Zw0bIhz6F-HcXap#Wnoyr!w27n`zc9{(ci+>`W zpBA(vqE}kV+QzjNX7rMfn(5N@5i_c>;HAL1Ue&H(9lbGtJ{jht`gr}B%_xL3{4lx& z$aw8&Pjfsj2B%{=`)SdOUs4B-KGHBu(ody!aiZ4mt@Ra>sQsL0YyM18=YpC*rKI-f zX`!wIeMBxx1T{@QfC=&3iGy$UYd2Yr?eTtFz{1#-gr4bHMRl{!KVHoQMTR#MK4F#3 z?PvY{xt0fIV(*Pf-)PYL+ua{arKFJaxJU~o@Cb$gZhU!jP9E0(0t3is=b=+&|nH>i|Ay z)rPy%5@)He&ZFh0_-`4PlFv$SDPI);Yhlo7;K86G7Y#2(cKr`irO~wWl&xZY7V*Zr z+zj50t9tvTG?Js%m4{98-D{ak|07N|dtdH=#K;uNGvC^NB4y<`-@>li<>c^y>OZTU|%&x{NEwJH#asQAAe#~4X_ zU33It$Cf(dG9jC1r&;cxLo>abiqj3TA285h6lx*ybs2?f-HRW%$X5rvTw1lTR)IlX zu~=2cn)oMt^>7GE56D|p3ga7O>atA6#4qzrwntBUbQUmV;bG)z(QvchM-ad)g@Erv zTSFcXa<5k+r;WzOjFl^8FW29SRj`<#_?s}$(kImDiwn>pPJzPh=||Lb z9;|s3QIGc*YoP6YEE+*Ht2OD2@c>zo)$rUekjj??vyl@T%H@aL6}O!~g2spxYD2T# zFM;;oVf{a#paGJ#(CHVM=eff7wnKk_2Q3Kcm(uX907fL6VCBuzL64E*O7TVER52-U zy`3UH_mzytO&R|7nhx@Oq7zvd2|hv`Vkw6|9>PPT3t2JO&ZQU-spdi?o=L$Fc- zy2{W}*uNFEvVQFdv@(yyVx#QEj|N^0o|^wzE7u6y`eGx_Eei)3VJHhmHR>6CjGiNi z}4J)x>0R)E8?kesH%}%#_G{2xuy(b2;Tm8#fp^arQ2-?8oB{i6yvq z;tElp(jw0-rO|%9ZAc%l{0B=40tit71p93s|Kd<83eirSmGE1_>Ky*kd*95KdQ%*O zt$@E$GYHCB4S{J|%LRTtfl*mLW;`;&{e{Zp2HI)0xhb2Y*fsY(ahNg3va{F`V4?u! z=re2a?_QWl(LMoT+PADKcw|5|p|-3aB@Ltr0?)(ip_NUC+ozy@uD3D@6|PSXOA@fT)E{v;??`iHE!!;-fl007=;~VKLt(X(W}?L zNq7R&+y)d&{Li>&Xv_Qv|T9 z#U!4-rJ&MO%2$3ITK+atoih8z^Z*tBz2Jy;6#aR~$6e!3zIP;M9(fkJo@Z@$Uis;E z#>`lxypUD>A6=~}rK{tMb#GM%^N(ul#*%=vzZ z$ArCHHoz9PqLuupAaP29SN9UP6p^zz?09U8SFopA;ghyB_gJ9NPXa`8yf*HD19nb$ zQ?QCST@?;r;)Fp=vxr2($f{h3u^O)(-O;h4qx5NODO7tnw2dX6 zB#zPozm90db_u9DNu1q&hg`PN0`2LVoPLVe8ATRA^5^fXrHJssxtTEB;B>JOHDPK; zLT$9XeMy^c&5d&*A))xDJYCF14tXz0>aE~^q)9gYUeclw2^UP8U+ zSGB@yl40_8vi?Y*d}NeJp2go3P;O?V3=r#OwD-q7FyST=vt~J-Z{$PLof6i1Q0IkW zocm*`pUNKrrnk?VXH!d6QUZ9Y`JPl5{g*x{hWA6+1Qr#5OBzZNRvkvlvq#WS+}5dM zsmuzCkc{JV6al}QisgvGA{M$9cfhBF!&UdAi)cMkhLRr;KaCHM#{&K14|UjKAV+@659 z;B*jYv?kKHw#hm>6XmXm6#U=e9wXFoa+2>f3AIG%!<{@W2xDKgL!U|U6!J!Bf1l1% zbf4VEg)^KIlk+5qX@%n3&ohBOO4;J(+_X64`M6&BmK#GDs9%Vv zhSAwsLmQqQ*!$Xp=y-E^yD|f^=^*OG0-*}5hgw2YCr$Jt7;kq~9RlVI)pH3zpe?L`PvWl!jrwB#&Mf zxAI7m-|ygiLy9J=knE`jSHZ(XxwMF^uy05-J0Clv?j++RL zf4Lb?!kMgF^}LQtDqzFG;Oa%NT}9L74=viQv^S)KRbJ67#J)=!{ZiZ~8<<-EI<>me zKctc5PW-9^mK#=$+N7l|E&8DuG}~7+k&@r;r-)FlMHw_IwG0!#65V$PUt#*nT5wEe zWq1a=rlWQq3^Sf^43&9vRYi^M!ecX=Ke++}TY{a$oE?$_UnOL=+w`tVc~Ez zPaPY+O#n; zHu&W%xk!GKgbe(!IehWi4(=OC!t+w6;QgQrEcMOM6$qW5@#Jt>rGtjb7BX%a^N^S_2tAV69gj)P=CRYpE7IRH@!)J-2V7@m(MIheQo(ii5c}k zt(qwG<1kBui*$6b_W`51Dls;V%4kKjD!ouva4C7L`%JZkr24x+vm0U|-DA!}B1&0L zxZIhs&Evl~)2sfAmZY_}BO4{Nf*R;YDo_j)J*7Hi^Y@e>4=Ml@etC*Xy$g00o_rP? z*>e%hit0-l7bkg7e33#m-a7UJFiWrtWd~a z^{>&yxj8&Xz2NC!R1Fk_TLfBuy6#hDm9pSeP4!T6n5@x3_o|Ps8_UpT zO1U&T-4~xFX#d^;R_tEguJ5T=@?6OW+tY{zQ)pNjE`yI%ZD006IxC)Fk*pUIAr~n<$ z*_9>h$DQ~X)%4s2SA}~Qci;2}Y=-SpGcj4*QUsUTT%fZGUpg5vmpiI-n=={su+XBn zTrWUsDA*7q_-331(J z3;;pjqlcbqjg``e(|5ItTEp>|99pHZi(R!@Hrn`W1@Y04_x zrXxgvl*8lf@VPi@%|v#~cRiP{YYpq{Yyj1UQ=471VOQ;I!&?V}Ao&fGW&7)~z+;bz zZBRsa?2dN;Esvh-2&kI)O2{FK=szBCT(3}l z?>)^jk8G9*mfG*h?D)Bd_R`Le;q+64U$gKY5rW34vKGB=o4mMKhbrd3-)Y6asAl|0 zE6MQLc}z+GN!Ct#bs7ae1`xDuc$)?rU7$stAbBe6^=ocyH3P?M`dwHAlNIKND}<$i z<0fKl6*ECZrHF#g0(4BTFu_aSpf5Du=dRIv^}e1rHek@h{hxQ_e#KL)Iz26ZiVara z2N1oyF#W-Ogq~r}tIP~aR?B%_#mZ+l>nqT=GT|iVQq7(G*|MVEt$OB#tiVj4{7nCq z8#dBgBxDv8&tK})wzN97pe@xkhHBd{dV+dus#N8;5(`UY5k&WI*&z?29E~ zhmDonGhuzkkL#<>u6YNOL^{otwqNwUHv$rOcVtva?mR+r?l3J>qvCMVVT^VH$nkGb zuUH41p*7eLJ|MV%b|(4hF1S;pD5%kU5f4}C`9Z!?;d4oGUUI#}XD|Q(C9qMcjSt`x*9$Hh!NQ~FCW?uKV zo^4ki<#r~+#BN8wp%iLuRO5Ie3N7*M->-I`sWT80*?1liA8zP=1m6%Pkrvtu&2_hh zCZ7I$_Fd8<6FMZ}s1~i-*Tord~xDvPLr44{wyWh*U8@1^?=o>Bki*z@iaaTT322MqmgCOk&0j z5@vxBI~;qO`5tV4yLI`QYGr)3m=u*%TooZ%sUB1P*`pO~6}dWKv>Odtsk#l&qTLWQ zM1x3d=U+0~(PPh5dq?HyXl8HeNhO}DD`w>Pt2ZCP9p7;rD!XHg26`_fl7&p^Jo62P zY9cmclz^Yr@i!Gkx}MzKuF_lkFCB+Z&y|{_S6=!?0`h+1sV?yG9C`OJC47HDhu9Fo zgj0ywN-#GCH+|s-dp%2Uzu10;ui@~CJu^GMdRH0tEigKs0cNPEk;?8V9qt!P$zPP8 z8lgrhxcaPo7MPvG1 zaxhb~M4GqPx!X4#X4FMRE#A|HOS@rLQq%MC52S@95_MA?tSyY%jgZ zbZS0U%F`4L;*ViBijMr`gKpwt+^_h@NnqTR8@PgPz&X%K^P_VkugRJ`n8oRI2 zx$ld!jb<5of5~DWHgvCkLmR&r43mD@ZoBoL8p~d7T)!=9zB^uCJDSNrnd1=Z&$bVL zm)+?KHlFlh(L1+aLJr#lU4c`UwtNH2PJ=X|E^SWNsmTM^4o$1MV=umy2$BX843#4q zoU(ayCI-bGTx=&dlTA)q=C_7ozdl`ct_(L6xJi!psIn#LOroHnFvJGVr4RjHGkN`e zHQiml)_!Uut9*SKXNLjkVIRQ}ReklFbPd*VSlDn)q4C|C#2wV*8XwX@FX|QwZc{_I zA}viglPPs8xI1raMH>%z_QEBxa%f4d+Nj-OvJ9BJ^!&`jm2eZSm$hxnQ`Q8&f;&H!48P;6 z>5d(f$xRg3pTFY!0Hyfipy43BciQYfB`>4>xK#Y&?rh|6LgdLU#CL55y@n15F;QoL zl=uS!TmDY=98MKH*R|-GOokZp?za`PLwKCPgddu_KBYzI2 zs_LV9gc^)zbo`+XlXbA7^SPbN1btJ-txjG}o~*KVOXtWK`~b8vJx(a6Q1%z5reKi| z2Ss@+@xpD+hq;x!TfLk2gC%{Y4Nj0COeTYS^!8pakR_{XsdP>M7EB5&7WpBlt+o|% z+xHXVAZh$l6-~5w_|gkU{4Pre+hemCzYzLAz>v6n>uq@1?&(J5r)+n7;uvG$zN~Gw zoxT)W|8i2c2NF3S+AR@rcb+_(YiyVsu2U^g`+4+*#y8cN#ra;Ax~;W@RieUd_zd(EjiD?h)tN_c1>p((-Q^r@{ZT?Cc{OB24F;t4u;THutI)0Le=CFj zTbV*mOLFkY_P=dk{f}F;OzRq_|Bn3|q1`E-Hvji^wCupyTd4oNn>J1j{{KFtRCjGY z1BsTuu`fu5Awu+jjDFwqmkjXF#8jEe-iG>`{zn$=vNPVPXCLO$#QoNDxnxI*D=gsu d`=8(EOX&mrCbW~ft+%&-mQ;|a_^2Q7e*iUV+%f Date: Sun, 23 Feb 2025 11:15:55 -0300 Subject: [PATCH 064/149] Add currency flag to my trades widget --- lib/features/trades/widgets/trades_list_item.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index 904bed2c..64e68d34 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -7,6 +7,7 @@ import 'package:mostro_mobile/core/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/shared/widgets/custom_card.dart'; +import 'package:mostro_mobile/shared/utils/currency_utils.dart'; class TradesListItem extends StatelessWidget { final NostrEvent trade; @@ -107,7 +108,7 @@ class TradesListItem extends StatelessWidget { _buildStyledTextSpan( context, 'for ', - '${trade.fiatAmount}', + '${trade.fiatAmount} ${CurrencyUtils.getFlagFromCurrency(trade.currency!)}', isValue: true, isBold: true, ), From a722f543d4cce3ff7a2575b1bd099fd88727d346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Sun, 23 Feb 2025 11:40:13 -0300 Subject: [PATCH 065/149] Fix alignment on currency flag --- lib/features/trades/widgets/trades_list_item.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index 64e68d34..9942ec9b 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -63,7 +63,7 @@ class TradesListItem extends StatelessWidget { _getOrderOffering(context, trade), const SizedBox(width: 16), Expanded( - flex: 4, + flex: 3, child: _buildPaymentMethod(context), ), ], @@ -108,12 +108,13 @@ class TradesListItem extends StatelessWidget { _buildStyledTextSpan( context, 'for ', - '${trade.fiatAmount} ${CurrencyUtils.getFlagFromCurrency(trade.currency!)}', + '${trade.fiatAmount}', isValue: true, isBold: true, ), TextSpan( - text: '${trade.currency} ', + text: + '${trade.currency} ${CurrencyUtils.getFlagFromCurrency(trade.currency!)} ', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: AppTheme.cream1, fontSize: 16.0, From c85d19803d83673f0031b56564580324c2d82c53 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Mon, 24 Feb 2025 16:54:09 -0800 Subject: [PATCH 066/149] Fix take order bug --- assets/data/fiat.json | 1355 +++++++++++++++++ lib/data/models/currency.dart | 38 + lib/data/models/order.dart | 6 + lib/data/repositories/session_manager.dart | 5 + .../home/providers/home_order_providers.dart | 7 +- .../notfiers/abstract_order_notifier.dart | 25 +- .../order/notfiers/add_order_notifier.dart | 42 + .../order/notfiers/order_notifier.dart | 19 +- .../providers/order_notifier_provider.dart | 9 + .../order/screens/add_order_screen.dart | 2 +- .../order/screens/take_order_screen.dart | 160 +- .../trades/notifiers/trades_notifier.dart | 58 - .../trades/notifiers/trades_state.dart | 7 - .../trades/providers/trades_provider.dart | 29 +- .../trades/screens/trades_screen.dart | 86 +- lib/features/trades/widgets/trades_list.dart | 10 +- lib/shared/providers/app_init_provider.dart | 9 +- .../providers/exchange_service_provider.dart | 19 +- lib/shared/widgets/currency_combo_box.dart | 4 +- pubspec.yaml | 1 + 20 files changed, 1689 insertions(+), 202 deletions(-) create mode 100644 assets/data/fiat.json create mode 100644 lib/data/models/currency.dart create mode 100644 lib/features/order/notfiers/add_order_notifier.dart delete mode 100644 lib/features/trades/notifiers/trades_notifier.dart delete mode 100644 lib/features/trades/notifiers/trades_state.dart diff --git a/assets/data/fiat.json b/assets/data/fiat.json new file mode 100644 index 00000000..ad811151 --- /dev/null +++ b/assets/data/fiat.json @@ -0,0 +1,1355 @@ +{ + "AED": { + "symbol": "AED", + "name": "United Arab Emirates Dirham", + "symbol_native": "د.إ.‏", + "decimal_digits": 2, + "rounding": 0, + "code": "AED", + "emoji": "🇦🇪", + "name_plural": "UAE dirhams", + "price": true + }, + "AFN": { + "symbol": "Af", + "name": "Afghan Afghani", + "symbol_native": "؋", + "decimal_digits": 0, + "rounding": 0, + "code": "AFN", + "emoji": "", + "name_plural": "Afghan Afghanis" + }, + "ALL": { + "symbol": "ALL", + "name": "Albanian Lek", + "symbol_native": "Lek", + "decimal_digits": 0, + "rounding": 0, + "code": "ALL", + "emoji": "", + "name_plural": "Albanian lekë" + }, + "AMD": { + "symbol": "AMD", + "name": "Armenian Dram", + "symbol_native": "դր.", + "decimal_digits": 0, + "rounding": 0, + "code": "AMD", + "emoji": "", + "name_plural": "Armenian drams" + }, + "ANG": { + "symbol": "ANG", + "name": "Netherlands Antillean Guilder", + "symbol_native": "NAƒ", + "decimal_digits": 2, + "rounding": 0, + "code": "ANG", + "emoji": "🇧🇶", + "name_plural": "ANG", + "price": true + }, + "AOA": { + "symbol": "AOA", + "name": "Angolan Kwanza", + "symbol_native": "Kz", + "decimal_digits": 2, + "rounding": 0, + "code": "AOA", + "emoji": "🇦🇴", + "name_plural": "AOA", + "price": true + }, + "ARS": { + "symbol": "AR$", + "name": "Peso argentino", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "ARS", + "emoji": "🇦🇷", + "name_plural": "Pesos", + "price": true, + "locale": "es-AR" + }, + "AUD": { + "symbol": "AU$", + "name": "Australian Dollar", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "AUD", + "emoji": "🇦🇺", + "name_plural": "Australian dollars", + "price": true, + "locale": "en-AU" + }, + "AZN": { + "symbol": "man.", + "name": "Azerbaijani Manat", + "symbol_native": "ман.", + "decimal_digits": 2, + "rounding": 0, + "code": "AZN", + "emoji": "🇦🇿", + "name_plural": "Azerbaijani manats", + "price": true + }, + "BAM": { + "symbol": "KM", + "name": "Bosnia-Herzegovina Convertible Mark", + "symbol_native": "KM", + "decimal_digits": 2, + "rounding": 0, + "code": "BAM", + "emoji": "", + "name_plural": "Bosnia-Herzegovina convertible marks" + }, + "BDT": { + "symbol": "Tk", + "name": "Bangladeshi Taka", + "symbol_native": "৳", + "decimal_digits": 2, + "rounding": 0, + "code": "BDT", + "emoji": "🇧🇩", + "name_plural": "Bangladeshi takas", + "price": true + }, + "BGN": { + "symbol": "BGN", + "name": "Bulgarian Lev", + "symbol_native": "лв.", + "decimal_digits": 2, + "rounding": 0, + "code": "BGN", + "emoji": "", + "name_plural": "Bulgarian leva" + }, + "BHD": { + "symbol": "BHD", + "name": "Bahraini Dinar", + "symbol_native": "د.ب.‏", + "decimal_digits": 3, + "rounding": 0, + "code": "BHD", + "emoji": "🇧🇭", + "name_plural": "Bahraini dinars", + "price": true + }, + "BIF": { + "symbol": "FBu", + "name": "Burundian Franc", + "symbol_native": "FBu", + "decimal_digits": 0, + "rounding": 0, + "code": "BIF", + "emoji": "", + "name_plural": "Burundian francs" + }, + "BMD": { + "symbol": "BMD", + "name": "Bermudan Dollar", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "BMD", + "emoji": "🇧🇲", + "name_plural": "Bermudan Dollar", + "price": true + }, + "BND": { + "symbol": "BN$", + "name": "Brunei Dollar", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "BND", + "emoji": "", + "name_plural": "Brunei dollars" + }, + "BOB": { + "symbol": "Bs", + "name": "Boliviano", + "symbol_native": "Bs", + "decimal_digits": 2, + "rounding": 0, + "code": "BOB", + "emoji": "🇧🇴", + "name_plural": "Bolivianos", + "price": true, + "locale": "es-BO" + }, + "BRL": { + "symbol": "R$", + "name": "Brazilian Real", + "symbol_native": "R$", + "decimal_digits": 2, + "rounding": 0, + "code": "BRL", + "emoji": "🇧🇷", + "name_plural": "Brazilian reals", + "price": true, + "locale": "pt-BR" + }, + "BWP": { + "symbol": "BWP", + "name": "Botswanan Pula", + "symbol_native": "P", + "decimal_digits": 2, + "rounding": 0, + "code": "BWP", + "emoji": "", + "name_plural": "Botswanan pulas", + "price": true + }, + "BYN": { + "symbol": "Br", + "name": "Belarusian Ruble", + "symbol_native": "руб.", + "decimal_digits": 2, + "rounding": 0, + "code": "BYN", + "emoji": "🇧🇾", + "name_plural": "Belarusian rubles", + "price": true + }, + "BZD": { + "symbol": "BZ$", + "name": "Belize Dollar", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "BZD", + "emoji": "", + "name_plural": "Belize dollars" + }, + "CAD": { + "symbol": "CA$", + "name": "Canadian Dollar", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "CAD", + "emoji": "🇨🇦", + "name_plural": "Canadian dollars", + "price": true, + "locale": "en-CA" + }, + "CDF": { + "symbol": "CDF", + "name": "Congolese Franc", + "symbol_native": "FrCD", + "decimal_digits": 2, + "rounding": 0, + "code": "CDF", + "emoji": "", + "name_plural": "Congolese francs", + "price": true + }, + "CHF": { + "symbol": "CHF", + "name": "Swiss Franc", + "symbol_native": "CHF", + "decimal_digits": 2, + "rounding": 0.05, + "code": "CHF", + "emoji": "🇨🇭", + "name_plural": "Swiss francs", + "price": true + }, + "CLP": { + "symbol": "CL$", + "name": "Peso chileno", + "symbol_native": "$", + "decimal_digits": 0, + "rounding": 0, + "code": "CLP", + "emoji": "🇨🇱", + "name_plural": "Pesos", + "price": true, + "locale": "es-CL" + }, + "CNY": { + "symbol": "CN¥", + "name": "Chinese Yuan", + "symbol_native": "CN¥", + "decimal_digits": 2, + "rounding": 0, + "code": "CNY", + "emoji": "🇨🇳", + "name_plural": "Chinese yuan", + "price": true + }, + "COP": { + "symbol": "CO$", + "name": "Peso colombiano", + "symbol_native": "$", + "decimal_digits": 0, + "rounding": 0, + "code": "COP", + "emoji": "🇨🇴", + "name_plural": "Pesos", + "price": true, + "locale": "es-CO" + }, + "CRC": { + "symbol": "₡", + "name": "Colón", + "symbol_native": "₡", + "decimal_digits": 0, + "rounding": 0, + "code": "CRC", + "emoji": "🇨🇷", + "name_plural": "Colones", + "price": true, + "locale": "es-CR" + }, + "CUP": { + "symbol": "CU$", + "name": "Peso cubano", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "CUP", + "emoji": "🇨🇺", + "name_plural": "Pesos", + "price": true, + "locale": "es-AR" + }, + "CVE": { + "symbol": "CV$", + "name": "Cape Verdean Escudo", + "symbol_native": "CV$", + "decimal_digits": 2, + "rounding": 0, + "code": "CVE", + "emoji": "", + "name_plural": "Cape Verdean escudos" + }, + "CZK": { + "symbol": "Kč", + "name": "Czech Republic Koruna", + "symbol_native": "Kč", + "decimal_digits": 2, + "rounding": 0, + "code": "CZK", + "emoji": "🇨🇿", + "name_plural": "Czech Republic korunas", + "price": true + }, + "DJF": { + "symbol": "Fdj", + "name": "Djiboutian Franc", + "symbol_native": "Fdj", + "decimal_digits": 0, + "rounding": 0, + "code": "DJF", + "emoji": "", + "name_plural": "Djiboutian francs" + }, + "DKK": { + "symbol": "Dkr", + "name": "Danish Krone", + "symbol_native": "kr", + "decimal_digits": 2, + "rounding": 0, + "code": "DKK", + "emoji": "🇩🇰", + "name_plural": "Danish kroner", + "price": true + }, + "DOP": { + "symbol": "RD$", + "name": "Peso dominicano", + "symbol_native": "RD$", + "decimal_digits": 2, + "rounding": 0, + "code": "DOP", + "emoji": "🇩🇴", + "name_plural": "Pesos", + "price": true, + "locale": "es-DO" + }, + "DZD": { + "symbol": "DA", + "name": "Algerian Dinar", + "symbol_native": "د.ج.‏", + "decimal_digits": 2, + "rounding": 0, + "code": "DZD", + "emoji": "🇩🇿", + "name_plural": "Algerian dinars", + "price": true + }, + "EEK": { + "symbol": "Ekr", + "name": "Estonian Kroon", + "symbol_native": "kr", + "decimal_digits": 2, + "rounding": 0, + "code": "EEK", + "emoji": "", + "name_plural": "Estonian kroons" + }, + "EGP": { + "symbol": "EGP", + "name": "Egyptian Pound", + "symbol_native": "ج.م.‏", + "decimal_digits": 2, + "rounding": 0, + "code": "EGP", + "emoji": "🇪🇬", + "name_plural": "Egyptian pounds", + "price": true + }, + "ERN": { + "symbol": "Nfk", + "name": "Eritrean Nakfa", + "symbol_native": "Nfk", + "decimal_digits": 2, + "rounding": 0, + "code": "ERN", + "emoji": "", + "name_plural": "Eritrean nakfas" + }, + "ETB": { + "symbol": "Br", + "name": "Ethiopian Birr", + "symbol_native": "Br", + "decimal_digits": 2, + "rounding": 0, + "code": "ETB", + "emoji": "🇪🇹", + "name_plural": "Ethiopian birrs", + "price": true + }, + "EUR": { + "symbol": "€", + "name": "Euro", + "symbol_native": "€", + "decimal_digits": 2, + "rounding": 0, + "code": "EUR", + "emoji": "🇪🇺", + "name_plural": "euros", + "price": true, + "locale": "de-DE" + }, + "GBP": { + "symbol": "£", + "name": "British Pound Sterling", + "symbol_native": "£", + "decimal_digits": 2, + "rounding": 0, + "code": "GBP", + "emoji": "🇬🇧", + "name_plural": "British pounds sterling", + "price": true + }, + "GEL": { + "symbol": "GEL", + "name": "Georgian Lari", + "symbol_native": "GEL", + "decimal_digits": 2, + "rounding": 0, + "code": "GEL", + "emoji": "🇬🇪", + "name_plural": "Georgian laris", + "price": true + }, + "GHS": { + "symbol": "GH₵", + "name": "Ghanaian Cedi", + "symbol_native": "GH₵", + "decimal_digits": 2, + "rounding": 0, + "code": "GHS", + "emoji": "🇬🇭", + "name_plural": "Ghanaian cedis", + "price": true + }, + "GNF": { + "symbol": "FG", + "name": "Guinean Franc", + "symbol_native": "FG", + "decimal_digits": 0, + "rounding": 0, + "code": "GNF", + "emoji": "", + "name_plural": "Guinean francs" + }, + "GTQ": { + "symbol": "GTQ", + "name": "Quetzal", + "symbol_native": "Q", + "decimal_digits": 2, + "rounding": 0, + "code": "GTQ", + "emoji": "🇬🇹", + "name_plural": "Quetzales", + "price": true + }, + "HKD": { + "symbol": "HK$", + "name": "Hong Kong Dollar", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "HKD", + "emoji": "🇭🇰", + "name_plural": "Hong Kong dollars", + "price": true + }, + "HNL": { + "symbol": "HNL", + "name": "Lempira", + "symbol_native": "L", + "decimal_digits": 2, + "rounding": 0, + "code": "HNL", + "emoji": "🇭🇳", + "name_plural": "Lempiras", + "price": true + }, + "HRK": { + "symbol": "kn", + "name": "Croatian Kuna", + "symbol_native": "kn", + "decimal_digits": 2, + "rounding": 0, + "code": "HRK", + "emoji": "", + "name_plural": "Croatian kunas" + }, + "HUF": { + "symbol": "Ft", + "name": "Hungarian Forint", + "symbol_native": "Ft", + "decimal_digits": 0, + "rounding": 0, + "code": "HUF", + "emoji": "🇭🇺", + "name_plural": "Hungarian forints", + "price": true + }, + "IDR": { + "symbol": "Rp", + "name": "Indonesian Rupiah", + "symbol_native": "Rp", + "decimal_digits": 0, + "rounding": 0, + "code": "IDR", + "emoji": "🇮🇩", + "name_plural": "Indonesian rupiahs", + "price": true + }, + "ILS": { + "symbol": "₪", + "name": "Israeli New Sheqel", + "symbol_native": "₪", + "decimal_digits": 2, + "rounding": 0, + "code": "ILS", + "emoji": "🇮🇱", + "name_plural": "Israeli new sheqels", + "price": true + }, + "INR": { + "symbol": "Rs", + "name": "Indian Rupee", + "symbol_native": "টকা", + "decimal_digits": 2, + "rounding": 0, + "code": "INR", + "emoji": "🇮🇳", + "name_plural": "Indian rupees", + "price": true + }, + "IQD": { + "symbol": "IQD", + "name": "Iraqi Dinar", + "symbol_native": "د.ع.‏", + "decimal_digits": 0, + "rounding": 0, + "code": "IQD", + "emoji": "", + "name_plural": "Iraqi dinars" + }, + "IRR": { + "symbol": "IRR", + "name": "Iranian Rial", + "symbol_native": "﷼", + "decimal_digits": 0, + "rounding": 0, + "code": "IRR", + "emoji": "", + "name_plural": "Iranian rials" + }, + "ISK": { + "symbol": "Ikr", + "name": "Icelandic Króna", + "symbol_native": "kr", + "decimal_digits": 0, + "rounding": 0, + "code": "ISK", + "emoji": "", + "name_plural": "Icelandic krónur" + }, + "JMD": { + "symbol": "J$", + "name": "Jamaican Dollar", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "JMD", + "emoji": "🇯🇲", + "name_plural": "Jamaican dollars", + "price": true + }, + "JOD": { + "symbol": "JD", + "name": "Jordanian Dinar", + "symbol_native": "د.أ.‏", + "decimal_digits": 3, + "rounding": 0, + "code": "JOD", + "emoji": "🇯🇴", + "name_plural": "Jordanian dinars", + "price": true + }, + "JPY": { + "symbol": "¥", + "name": "Japanese Yen", + "symbol_native": "¥", + "decimal_digits": 0, + "rounding": 0, + "code": "JPY", + "emoji": "🇯🇵", + "name_plural": "Japanese yen", + "price": true + }, + "KES": { + "symbol": "Ksh", + "name": "Kenyan Shilling", + "symbol_native": "Ksh", + "decimal_digits": 2, + "rounding": 0, + "code": "KES", + "emoji": "🇰🇪", + "name_plural": "Kenyan shillings", + "price": true + }, + "KGS": { + "symbol": "KGS", + "name": "Kyrgystani Som", + "symbol_native": "KGS", + "decimal_digits": 2, + "rounding": 0, + "code": "KGS", + "emoji": "🇰🇬", + "name_plural": "Kyrgystani Som", + "price": true + }, + "KHR": { + "symbol": "KHR", + "name": "Cambodian Riel", + "symbol_native": "៛", + "decimal_digits": 2, + "rounding": 0, + "code": "KHR", + "emoji": "", + "name_plural": "Cambodian riels" + }, + "KMF": { + "symbol": "CF", + "name": "Comorian Franc", + "symbol_native": "FC", + "decimal_digits": 0, + "rounding": 0, + "code": "KMF", + "emoji": "", + "name_plural": "Comorian francs" + }, + "KRW": { + "symbol": "₩", + "name": "South Korean Won", + "symbol_native": "₩", + "decimal_digits": 0, + "rounding": 0, + "code": "KRW", + "emoji": "🇰🇷", + "name_plural": "South Korean won", + "price": true + }, + "KWD": { + "symbol": "KD", + "name": "Kuwaiti Dinar", + "symbol_native": "د.ك.‏", + "decimal_digits": 3, + "rounding": 0, + "code": "KWD", + "emoji": "", + "name_plural": "Kuwaiti dinars" + }, + "KZT": { + "symbol": "KZT", + "name": "Kazakhstani Tenge", + "symbol_native": "тңг.", + "decimal_digits": 2, + "rounding": 0, + "code": "KZT", + "emoji": "🇰🇿", + "name_plural": "Kazakhstani tenges", + "price": true + }, + "LBP": { + "symbol": "L.L.", + "name": "Lebanese Pound", + "symbol_native": "ل.ل.‏", + "decimal_digits": 0, + "rounding": 0, + "code": "LBP", + "emoji": "🇱🇧", + "name_plural": "Lebanese pounds", + "price": true + }, + "LKR": { + "symbol": "SLRs", + "name": "Sri Lankan Rupee", + "symbol_native": "SL Re", + "decimal_digits": 2, + "rounding": 0, + "code": "LKR", + "emoji": "🇱🇰", + "name_plural": "Sri Lankan rupees", + "price": true + }, + "LTL": { + "symbol": "Lt", + "name": "Lithuanian Litas", + "symbol_native": "Lt", + "decimal_digits": 2, + "rounding": 0, + "code": "LTL", + "emoji": "", + "name_plural": "Lithuanian litai" + }, + "LVL": { + "symbol": "Ls", + "name": "Latvian Lats", + "symbol_native": "Ls", + "decimal_digits": 2, + "rounding": 0, + "code": "LVL", + "emoji": "", + "name_plural": "Latvian lati" + }, + "LYD": { + "symbol": "LD", + "name": "Libyan Dinar", + "symbol_native": "د.ل.‏", + "decimal_digits": 3, + "rounding": 0, + "code": "LYD", + "emoji": "", + "name_plural": "Libyan dinars" + }, + "MAD": { + "symbol": "MAD", + "name": "Moroccan Dirham", + "symbol_native": "د.م.‏", + "decimal_digits": 2, + "rounding": 0, + "code": "MAD", + "emoji": "🇲🇦", + "name_plural": "Moroccan dirhams", + "price": true + }, + "MDL": { + "symbol": "MDL", + "name": "Moldovan Leu", + "symbol_native": "MDL", + "decimal_digits": 2, + "rounding": 0, + "code": "MDL", + "emoji": "", + "name_plural": "Moldovan lei" + }, + "MGA": { + "symbol": "MGA", + "name": "Malagasy Ariary", + "symbol_native": "MGA", + "decimal_digits": 0, + "rounding": 0, + "code": "MGA", + "emoji": "", + "name_plural": "Malagasy Ariaries" + }, + "MKD": { + "symbol": "MKD", + "name": "Macedonian Denar", + "symbol_native": "MKD", + "decimal_digits": 2, + "rounding": 0, + "code": "MKD", + "emoji": "", + "name_plural": "Macedonian denari" + }, + "MLC": { + "symbol": "MLC", + "name": "Moneda Libremente Convertible", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "MLC", + "emoji": "🇨🇺", + "name_plural": "MLC", + "price": true, + "locale": "es-AR" + }, + "MMK": { + "symbol": "MMK", + "name": "Myanma Kyat", + "symbol_native": "K", + "decimal_digits": 0, + "rounding": 0, + "code": "MMK", + "emoji": "", + "name_plural": "Myanma kyats" + }, + "MOP": { + "symbol": "MOP$", + "name": "Macanese Pataca", + "symbol_native": "MOP$", + "decimal_digits": 2, + "rounding": 0, + "code": "MOP", + "emoji": "", + "name_plural": "Macanese patacas" + }, + "MUR": { + "symbol": "MURs", + "name": "Mauritian Rupee", + "symbol_native": "MURs", + "decimal_digits": 0, + "rounding": 0, + "code": "MUR", + "emoji": "", + "name_plural": "Mauritian rupees" + }, + "MXN": { + "symbol": "MX$", + "name": "Peso mexicano", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "MXN", + "emoji": "🇲🇽", + "name_plural": "Pesos", + "price": true, + "locale": "es-MX" + }, + "MYR": { + "symbol": "RM", + "name": "Malaysian Ringgit", + "symbol_native": "RM", + "decimal_digits": 2, + "rounding": 0, + "code": "MYR", + "emoji": "🇲🇾", + "name_plural": "Malaysian ringgits", + "price": true + }, + "MZN": { + "symbol": "MTn", + "name": "Mozambican Metical", + "symbol_native": "MTn", + "decimal_digits": 2, + "rounding": 0, + "code": "MZN", + "emoji": "", + "name_plural": "Mozambican meticals" + }, + "NAD": { + "symbol": "N$", + "name": "Namibian Dollar", + "symbol_native": "N$", + "decimal_digits": 2, + "rounding": 0, + "code": "NAD", + "emoji": "🇳🇦", + "name_plural": "Namibian dollars", + "price": true + }, + "NGN": { + "symbol": "₦", + "name": "Nigerian Naira", + "symbol_native": "₦", + "decimal_digits": 2, + "rounding": 0, + "code": "NGN", + "emoji": "🇳🇬", + "name_plural": "Nigerian nairas", + "price": true + }, + "NIO": { + "symbol": "C$", + "name": "Nicaraguan Córdoba", + "symbol_native": "C$", + "decimal_digits": 2, + "rounding": 0, + "code": "NIO", + "emoji": "🇳🇮", + "name_plural": "Nicaraguan córdobas", + "price": true + }, + "NOK": { + "symbol": "Nkr", + "name": "Norwegian Krone", + "symbol_native": "kr", + "decimal_digits": 2, + "rounding": 0, + "code": "NOK", + "emoji": "🇳🇴", + "name_plural": "Norwegian kroner", + "price": true + }, + "NPR": { + "symbol": "NPRs", + "name": "Nepalese Rupee", + "symbol_native": "नेरू", + "decimal_digits": 2, + "rounding": 0, + "code": "NPR", + "emoji": "🇳🇵", + "name_plural": "Nepalese rupees", + "price": true + }, + "NZD": { + "symbol": "NZ$", + "name": "New Zealand Dollar", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "NZD", + "emoji": "🇳🇿", + "name_plural": "New Zealand dollars", + "price": true + }, + "OMR": { + "symbol": "OMR", + "name": "Omani Rial", + "symbol_native": "ر.ع.‏", + "decimal_digits": 3, + "rounding": 0, + "code": "OMR", + "emoji": "", + "name_plural": "Omani rials" + }, + "PAB": { + "symbol": "B/.", + "name": "Panamanian Balboa", + "symbol_native": "B/.", + "decimal_digits": 2, + "rounding": 0, + "code": "PAB", + "emoji": "🇵🇦", + "name_plural": "Balboas", + "price": true + }, + "PEN": { + "symbol": "S/.", + "name": "Peruvian Nuevo Sol", + "symbol_native": "S/.", + "decimal_digits": 2, + "rounding": 0, + "code": "PEN", + "emoji": "🇵🇪", + "name_plural": "Nuevos soles peruanos", + "price": true, + "locale": "es-PE" + }, + "PHP": { + "symbol": "₱", + "name": "Philippine Peso", + "symbol_native": "₱", + "decimal_digits": 2, + "rounding": 0, + "code": "PHP", + "emoji": "🇵🇭", + "name_plural": "Pesos", + "price": true + }, + "PKR": { + "symbol": "PKRs", + "name": "Pakistani Rupee", + "symbol_native": "₨", + "decimal_digits": 0, + "rounding": 0, + "code": "PKR", + "emoji": "🇵🇰", + "name_plural": "Pakistani rupees", + "price": true + }, + "PLN": { + "symbol": "zł", + "name": "Polish Zloty", + "symbol_native": "zł", + "decimal_digits": 2, + "rounding": 0, + "code": "PLN", + "emoji": "🇵🇱", + "name_plural": "Polish zlotys", + "price": true + }, + "PYG": { + "symbol": "₲", + "name": "Paraguayan Guarani", + "symbol_native": "₲", + "decimal_digits": 0, + "rounding": 0, + "code": "PYG", + "emoji": "🇵🇾", + "name_plural": "Guaranis", + "price": true + }, + "QAR": { + "symbol": "QR", + "name": "Qatari Rial", + "symbol_native": "ر.ق.‏", + "decimal_digits": 2, + "rounding": 0, + "code": "QAR", + "emoji": "🇶🇦", + "name_plural": "Qatari rials", + "price": true + }, + "RON": { + "symbol": "RON", + "name": "Romanian Leu", + "symbol_native": "RON", + "decimal_digits": 2, + "rounding": 0, + "code": "RON", + "emoji": "🇷🇴", + "name_plural": "Romanian lei", + "price": true + }, + "RSD": { + "symbol": "din.", + "name": "Serbian Dinar", + "symbol_native": "дин.", + "decimal_digits": 0, + "rounding": 0, + "code": "RSD", + "emoji": "", + "name_plural": "Serbian dinars", + "price": true + }, + "RUB": { + "symbol": "RUB", + "name": "руб", + "symbol_native": "₽.", + "decimal_digits": 2, + "rounding": 0, + "code": "RUB", + "emoji": "🇷🇺", + "name_plural": "руб", + "price": true + }, + "RWF": { + "symbol": "RWF", + "name": "Rwandan Franc", + "symbol_native": "FR", + "decimal_digits": 0, + "rounding": 0, + "code": "RWF", + "emoji": "", + "name_plural": "Rwandan francs" + }, + "SAR": { + "symbol": "SR", + "name": "Saudi Riyal", + "symbol_native": "ر.س.‏", + "decimal_digits": 2, + "rounding": 0, + "code": "SAR", + "emoji": "🇸🇦", + "name_plural": "Saudi riyals", + "price": true + }, + "SDG": { + "symbol": "SDG", + "name": "Sudanese Pound", + "symbol_native": "SDG", + "decimal_digits": 2, + "rounding": 0, + "code": "SDG", + "emoji": "", + "name_plural": "Sudanese pounds" + }, + "SEK": { + "symbol": "Skr", + "name": "Swedish Krona", + "symbol_native": "kr", + "decimal_digits": 2, + "rounding": 0, + "code": "SEK", + "emoji": "🇸🇪", + "name_plural": "Swedish kronor", + "price": true + }, + "SGD": { + "symbol": "S$", + "name": "Singapore Dollar", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "SGD", + "emoji": "🇸🇬", + "name_plural": "Singapore dollars", + "price": true + }, + "SOS": { + "symbol": "Ssh", + "name": "Somali Shilling", + "symbol_native": "Ssh", + "decimal_digits": 0, + "rounding": 0, + "code": "SOS", + "emoji": "", + "name_plural": "Somali shillings" + }, + "SYP": { + "symbol": "SY£", + "name": "Syrian Pound", + "symbol_native": "ل.س.‏", + "decimal_digits": 0, + "rounding": 0, + "code": "SYP", + "emoji": "", + "name_plural": "Syrian pounds" + }, + "THB": { + "symbol": "฿", + "name": "Thai Baht", + "symbol_native": "฿", + "decimal_digits": 2, + "rounding": 0, + "code": "THB", + "emoji": "🇹🇭", + "name_plural": "Thai baht", + "price": true + }, + "TND": { + "symbol": "DT", + "name": "Tunisian Dinar", + "symbol_native": "د.ت.‏", + "decimal_digits": 3, + "rounding": 0, + "code": "TND", + "emoji": "🇹🇳", + "name_plural": "Tunisian dinars", + "price": true + }, + "TOP": { + "symbol": "T$", + "name": "Tongan Paʻanga", + "symbol_native": "T$", + "decimal_digits": 2, + "rounding": 0, + "code": "TOP", + "emoji": "", + "name_plural": "Tongan paʻanga" + }, + "TRY": { + "symbol": "TL", + "name": "Turkish Lira", + "symbol_native": "TL", + "decimal_digits": 2, + "rounding": 0, + "code": "TRY", + "emoji": "🇹🇷", + "name_plural": "Turkish Lira", + "price": true + }, + "TTD": { + "symbol": "TT$", + "name": "Trinidad and Tobago Dollar", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "TTD", + "emoji": "🇹🇹", + "name_plural": "Trinidad and Tobago dollars", + "price": true + }, + "TWD": { + "symbol": "NT$", + "name": "New Taiwan Dollar", + "symbol_native": "NT$", + "decimal_digits": 2, + "rounding": 0, + "code": "TWD", + "emoji": "🇹🇼", + "name_plural": "New Taiwan dollars", + "price": true + }, + "TZS": { + "symbol": "TSh", + "name": "Tanzanian Shilling", + "symbol_native": "TSh", + "decimal_digits": 0, + "rounding": 0, + "code": "TZS", + "emoji": "🇹🇿", + "name_plural": "Tanzanian shillings", + "price": true + }, + "UAH": { + "symbol": "₴", + "name": "Ukrainian Hryvnia", + "symbol_native": "₴", + "decimal_digits": 2, + "rounding": 0, + "code": "UAH", + "emoji": "🇺🇦", + "name_plural": "Ukrainian hryvnias", + "price": true + }, + "UGX": { + "symbol": "USh", + "name": "Ugandan Shilling", + "symbol_native": "USh", + "decimal_digits": 0, + "rounding": 0, + "code": "UGX", + "emoji": "🇺🇬", + "name_plural": "Ugandan shillings", + "price": true + }, + "USD": { + "symbol": "$", + "name": "US Dollar", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "USD", + "emoji": "🇺🇸", + "name_plural": "US dollars", + "price": true, + "locale": "en-US" + }, + "UYU": { + "symbol": "$U", + "name": "Peso uruguayo", + "symbol_native": "$", + "decimal_digits": 2, + "rounding": 0, + "code": "UYU", + "emoji": "🇺🇾", + "name_plural": "Pesos", + "price": true, + "locale": "es-UY" + }, + "UZS": { + "symbol": "UZS", + "name": "Uzbekistan Som", + "symbol_native": "UZS", + "decimal_digits": 0, + "rounding": 0, + "code": "UZS", + "emoji": "🇺🇿", + "name_plural": "Uzbekistan som", + "price": true + }, + "VES": { + "symbol": "Bs.", + "name": "Bolívar", + "symbol_native": "Bs.", + "decimal_digits": 2, + "rounding": 0, + "code": "VES", + "emoji": "🇻🇪", + "name_plural": "Bolívares", + "price": true, + "locale": "es-VE" + }, + "VND": { + "symbol": "₫", + "name": "Vietnamese Dong", + "symbol_native": "₫", + "decimal_digits": 0, + "rounding": 0, + "code": "VND", + "emoji": "🇻🇳", + "name_plural": "Vietnamese dong", + "price": true + }, + "XAF": { + "symbol": "FCFA", + "name": "CFA Franc BEAC", + "symbol_native": "FCFA", + "decimal_digits": 0, + "rounding": 0, + "code": "XAF", + "emoji": "🏳️", + "name_plural": "CFA francs BEAC", + "price": true + }, + "XOF": { + "symbol": "CFA", + "name": "CFA Franc BCEAO", + "symbol_native": "CFA", + "decimal_digits": 0, + "rounding": 0, + "code": "XOF", + "emoji": "🏳️", + "name_plural": "CFA francs BCEAO", + "price": true + }, + "YER": { + "symbol": "YR", + "name": "Yemeni Rial", + "symbol_native": "ر.ي.‏", + "decimal_digits": 0, + "rounding": 0, + "code": "YER", + "emoji": "", + "name_plural": "Yemeni rials" + }, + "ZAR": { + "symbol": "R", + "name": "South African Rand", + "symbol_native": "R", + "decimal_digits": 2, + "rounding": 0, + "code": "ZAR", + "emoji": "🇿🇦", + "name_plural": "South African rand", + "price": true + }, + "ZMK": { + "symbol": "ZK", + "name": "Zambian Kwacha", + "symbol_native": "ZK", + "decimal_digits": 0, + "rounding": 0, + "code": "ZMK", + "emoji": "", + "name_plural": "Zambian kwachas" + }, + "ZWL": { + "symbol": "ZWL$", + "name": "Zimbabwean Dollar", + "symbol_native": "ZWL$", + "decimal_digits": 0, + "rounding": 0, + "code": "ZWL", + "emoji": "🇿🇼", + "name_plural": "Zimbabwean Dollar" + } +} \ No newline at end of file diff --git a/lib/data/models/currency.dart b/lib/data/models/currency.dart new file mode 100644 index 00000000..115d1eb5 --- /dev/null +++ b/lib/data/models/currency.dart @@ -0,0 +1,38 @@ +class Currency { + final String symbol; + final String name; + final String symbolNative; + final int decimalDigits; + final String code; + final String emoji; + final String namePlural; + final bool price; + String? locale; + + + Currency({ + required this.symbol, + required this.name, + required this.symbolNative, + required this.code, + required this.emoji, + required this.decimalDigits, + required this.namePlural, + required this.price, + this.locale, + }); + + factory Currency.fromJson(Map json) { + return Currency( + symbol: json['symbol'], + name: json['name'], + symbolNative: json['symbol_native'], + code: json['code'], + emoji: json['emoji'], + decimalDigits: json['decimal_digits'], + namePlural: json['name_plural'], + price: json['price'] ?? false, + locale: json['locale'], + ); + } +} diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart index a71d1904..40a6150d 100644 --- a/lib/data/models/order.dart +++ b/lib/data/models/order.dart @@ -17,6 +17,8 @@ class Order implements Payload { final int premium; final String? masterBuyerPubkey; final String? masterSellerPubkey; + final String? buyerTradePubkey; + final String? sellerTradePubkey; final String? buyerInvoice; final int? createdAt; final int? expiresAt; @@ -36,6 +38,8 @@ class Order implements Payload { this.premium = 0, this.masterBuyerPubkey, this.masterSellerPubkey, + this.buyerTradePubkey, + this.sellerTradePubkey, this.buyerInvoice, this.createdAt = 0, this.expiresAt, @@ -99,6 +103,8 @@ class Order implements Payload { premium: json['premium'], masterBuyerPubkey: json['master_buyer_pubkey'], masterSellerPubkey: json['master_seller_pubkey'], + buyerTradePubkey: json['buyer_trade_pubkey'], + sellerTradePubkey: json['seller_trade_pubkey'], buyerInvoice: json['buyer_invoice'], createdAt: json['created_at'], expiresAt: json['expires_at'], diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 85e791db..fc19d75c 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -3,6 +3,7 @@ import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/session_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; class SessionManager { final Logger _logger = Logger(); @@ -37,6 +38,10 @@ class SessionManager { } } + void updateSettings(Settings settings) { + fullPrivacyMode = settings.fullPrivacyMode; + } + /// Creates a new session, storing it both in memory and in the database. Future newSession({String? orderId}) async { final masterKey = await _keyManager.getMasterKey(); diff --git a/lib/features/home/providers/home_order_providers.dart b/lib/features/home/providers/home_order_providers.dart index 68ccfb88..ceaef7d4 100644 --- a/lib/features/home/providers/home_order_providers.dart +++ b/lib/features/home/providers/home_order_providers.dart @@ -5,16 +5,11 @@ import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; -final allOrdersProvider = StreamProvider>((ref) { - final repository = ref.watch(orderRepositoryProvider); - repository.subscribeToOrders(); - return repository.eventsStream; -}); final homeOrderTypeProvider = StateProvider((ref) => OrderType.sell); final filteredOrdersProvider = Provider>((ref) { - final allOrdersAsync = ref.watch(allOrdersProvider); + final allOrdersAsync = ref.watch(orderEventsProvider); final orderType = ref.watch(homeOrderTypeProvider); final sessionManager = ref.watch(sessionManagerProvider); diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 948f166a..4d1cae3d 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -16,7 +16,7 @@ class AbstractOrderNotifier extends StateNotifier { final MostroRepository orderRepository; final Ref ref; final String orderId; - StreamSubscription? _orderSubscription; + StreamSubscription? orderSubscription; final logger = Logger(); AbstractOrderNotifier( @@ -27,7 +27,7 @@ class AbstractOrderNotifier extends StateNotifier { Future subscribe(Stream stream) async { try { - _orderSubscription = stream.listen((order) { + orderSubscription = stream.listen((order) { state = order; handleOrderUpdate(); }); @@ -91,21 +91,38 @@ class AbstractOrderNotifier extends StateNotifier { case Action.buyerTookOrder: final order = state.getPayload(); notifProvider.showInformation(state.action, values: { - 'buyer_npub': order?.masterBuyerPubkey, + 'buyer_npub': order?.buyerTradePubkey ?? 'Unknown', 'fiat_code': order?.fiatCode, 'fiat_amount': order?.fiatAmount, 'payment_method': order?.paymentMethod, }); + navProvider.go('/'); break; case Action.canceled: navProvider.go('/'); notifProvider.showInformation(state.action, values: {'id': orderId}); break; + case Action.holdInvoicePaymentAccepted: + final order = state.getPayload(); + notifProvider.showInformation(state.action, values: { + 'seller_npub': order?.sellerTradePubkey ?? 'Unknown', + 'id': order?.id, + 'fiat_code': order?.fiatCode, + 'fiat_amount': order?.fiatAmount, + 'payment_method': order?.paymentMethod, + }); + navProvider.go('/'); + break; case Action.fiatSentOk: case Action.holdInvoicePaymentSettled: case Action.rate: case Action.rateReceived: case Action.cooperativeCancelInitiatedByYou: + notifProvider.showInformation(state.action, values: { + 'id': state.id, + }); + navProvider.go('/'); + break; case Action.disputeInitiatedByYou: case Action.adminSettled: notifProvider.showInformation(state.action); @@ -118,7 +135,7 @@ class AbstractOrderNotifier extends StateNotifier { @override void dispose() { - _orderSubscription?.cancel(); + orderSubscription?.cancel(); super.dispose(); } } diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart new file mode 100644 index 00000000..faff0e97 --- /dev/null +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -0,0 +1,42 @@ +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; + +class AddOrderNotifier extends AbstractOrderNotifier { + AddOrderNotifier(super.orderRepository, super.orderId, super.ref); + + @override + Future subscribe(Stream stream) async { + try { + orderSubscription = stream.listen((order) { + state = order; + if (order.action == Action.newOrder) { + confirmOrder(order); + } else { + handleOrderUpdate(); + } + }); + } catch (e) { + handleError(e); + } + } + + // This method would be called when the order is confirmed. + Future confirmOrder(MostroMessage confirmedOrder) async { + // Extract the confirmed (real) order id. + final confirmedOrderId = confirmedOrder.id; + final newNotifier = + ref.read(orderNotifierProvider(confirmedOrderId!).notifier); + newNotifier.reSubscribe(); + dispose(); + } + + Future submitOrder(Order order) async { + final message = + MostroMessage(action: Action.newOrder, id: null, payload: order); + final stream = await orderRepository.publishOrder(message); + await subscribe(stream); + } +} diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 50e0c1a7..fca78e39 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -8,13 +8,20 @@ class OrderNotifier extends AbstractOrderNotifier { OrderNotifier(super.orderRepository, super.orderId, super.ref); Future reSubscribe() async { - final existingMessage = await orderRepository.getOrderById(orderId); - if (existingMessage == null) { - logger.e('Order $orderId not found in repository; subscription aborted.'); - return; - } final stream = orderRepository.resubscribeOrder(orderId); - await subscribe(stream); + Timer? debounceTimer; + stream.listen((order) { + // Cancel any previously scheduled update. + debounceTimer?.cancel(); + // Schedule a new update after a debounce duration. + debounceTimer = Timer(const Duration(milliseconds: 300), () { + state = order; + debounceTimer?.cancel(); + subscribe(stream); + }); + }, onDone: () { + debounceTimer?.cancel(); + }, onError: handleError); } Future takeSellOrder( diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index 7054b9a4..e1e1a191 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/order/notfiers/add_order_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; @@ -16,5 +17,13 @@ final orderNotifierProvider = }, ); +final addOrderNotifierProvider = + StateNotifierProvider.family( + (ref, orderId) { + final repo = ref.watch(mostroRepositoryProvider); + return AddOrderNotifier(repo, orderId, ref); + }, +); + // This provider tracks the currently selected OrderType tab final orderTypeProvider = StateProvider((ref) => OrderType.sell); diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 9fd2ebcb..1dd2c48e 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -470,7 +470,7 @@ class _AddOrderScreenState extends ConsumerState { // Generate a unique temporary ID for this new order final uuid = Uuid(); final tempOrderId = uuid.v4(); - final notifier = ref.read(orderNotifierProvider(tempOrderId).notifier); + final notifier = ref.read(addOrderNotifierProvider(tempOrderId).notifier); final fiatAmount = _maxFiatAmount != null ? 0 : _minFiatAmount; final minAmount = _maxFiatAmount != null ? _minFiatAmount : null; diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 9e95de31..223c058c 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -48,7 +48,8 @@ class TakeOrderScreen extends ConsumerWidget { const SizedBox(height: 24), _buildCountDownTime(order.expirationDate), const SizedBox(height: 36), - _buildActionButtons(context, ref, order.orderId!), + // Pass the full order to the action buttons widget. + _buildActionButtons(context, ref, order), ], ), ); @@ -67,8 +68,8 @@ class TakeOrderScreen extends ConsumerWidget { final premiumText = premium >= 0 ? premium == 0 ? '' - : 'with a +{premium}% premium' - : 'with a -{premium}% discount'; + : 'with a +$premium% premium' + : 'with a -$premium% discount'; final method = order.paymentMethods.isNotEmpty ? order.paymentMethods[0] : 'No payment method'; @@ -107,35 +108,36 @@ class TakeOrderScreen extends ConsumerWidget { Widget _buildOrderId(BuildContext context) { return CustomCard( - padding: const EdgeInsets.all(2), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SelectableText( - orderId, - style: TextStyle(color: AppTheme.mostroGreen), - ), - const SizedBox(width: 16), - IconButton( - onPressed: () { - Clipboard.setData(ClipboardData(text: orderId)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Order ID copied to clipboard'), - duration: Duration(seconds: 2), - ), - ); - }, - icon: const Icon(Icons.copy), - style: IconButton.styleFrom( - foregroundColor: AppTheme.mostroGreen, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + padding: const EdgeInsets.all(2), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText( + orderId, + style: TextStyle(color: AppTheme.mostroGreen), + ), + const SizedBox(width: 16), + IconButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: orderId)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Order ID copied to clipboard'), + duration: Duration(seconds: 2), ), + ); + }, + icon: const Icon(Icons.copy), + style: IconButton.styleFrom( + foregroundColor: AppTheme.mostroGreen, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - ) - ], - )); + ), + ) + ], + ), + ); } Widget _buildCountDownTime(DateTime expiration) { @@ -158,9 +160,9 @@ class TakeOrderScreen extends ConsumerWidget { } Widget _buildActionButtons( - BuildContext context, WidgetRef ref, String orderId) { + BuildContext context, WidgetRef ref, NostrEvent order) { final orderDetailsNotifier = - ref.read(orderNotifierProvider(orderId).notifier); + ref.read(orderNotifierProvider(order.orderId!).notifier); return Row( mainAxisAlignment: MainAxisAlignment.center, @@ -173,16 +175,89 @@ class TakeOrderScreen extends ConsumerWidget { child: const Text('CLOSE'), ), const SizedBox(width: 16), - // Take Order ElevatedButton( onPressed: () async { - final fiatAmount = int.tryParse(_fiatAmountController.text.trim()); - if (orderType == OrderType.buy) { - await orderDetailsNotifier.takeBuyOrder(orderId, fiatAmount); + // Check if the order is a range order. + if (order.fiatAmount.maximum != null) { + final enteredAmount = await showDialog( + context: context, + builder: (BuildContext context) { + String? errorText; + return StatefulBuilder( + builder: (BuildContext context, + void Function(void Function()) setState) { + return AlertDialog( + title: const Text('Enter Amount'), + content: TextField( + controller: _fiatAmountController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: + 'Enter an amount between ${order.fiatAmount.minimum} and ${order.fiatAmount.maximum}', + errorText: errorText, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final inputAmount = int.tryParse( + _fiatAmountController.text.trim()); + if (inputAmount == null) { + setState(() { + errorText = "Please enter a valid number."; + }); + } else if (inputAmount < + order.fiatAmount.minimum || + inputAmount > order.fiatAmount.maximum!) { + setState(() { + errorText = + "Amount must be between ${order.fiatAmount.minimum} and ${order.fiatAmount.maximum}."; + }); + } else { + Navigator.of(context).pop(inputAmount); + } + }, + child: const Text('Submit'), + ), + ], + ); + }, + ); + }, + ); + + if (enteredAmount != null) { + if (orderType == OrderType.buy) { + await orderDetailsNotifier.takeBuyOrder( + order.orderId!, enteredAmount); + } else { + final lndAddress = _lndAddressController.text.trim(); + await orderDetailsNotifier.takeSellOrder( + order.orderId!, + enteredAmount, + lndAddress.isEmpty ? null : lndAddress, + ); + } + } } else { - final lndAddress = _lndAddressController.text.trim(); - await orderDetailsNotifier.takeSellOrder( - orderId, fiatAmount, lndAddress.isEmpty ? null : lndAddress); + // Not a range order – use the existing logic. + final fiatAmount = + int.tryParse(_fiatAmountController.text.trim()); + if (orderType == OrderType.buy) { + await orderDetailsNotifier.takeBuyOrder( + order.orderId!, fiatAmount); + } else { + final lndAddress = _lndAddressController.text.trim(); + await orderDetailsNotifier.takeSellOrder( + order.orderId!, + fiatAmount, + lndAddress.isEmpty ? null : lndAddress, + ); + } } }, style: ElevatedButton.styleFrom( @@ -195,20 +270,13 @@ class TakeOrderScreen extends ConsumerWidget { } String formatDateTime(DateTime dt) { - // Format the main parts (e.g. Mon Dec 30 2024 08:16:00) final dateFormatter = DateFormat('EEE MMM dd yyyy HH:mm:ss'); final formattedDate = dateFormatter.format(dt); - - // Get the timezone offset in hours and minutes. final offset = dt.timeZoneOffset; final sign = offset.isNegative ? '-' : '+'; - // Absolute values so the sign is handled separately. final hours = offset.inHours.abs().toString().padLeft(2, '0'); final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, '0'); - - // Get the timezone abbreviation final timeZoneName = dt.timeZoneName; - return '$formattedDate GMT $sign$hours$minutes ($timeZoneName)'; } } diff --git a/lib/features/trades/notifiers/trades_notifier.dart b/lib/features/trades/notifiers/trades_notifier.dart deleted file mode 100644 index b116c03f..00000000 --- a/lib/features/trades/notifiers/trades_notifier.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'dart:async'; -import 'package:dart_nostr/nostr/model/event/event.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/nostr_event.dart'; -import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; -import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; -import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; -import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; - -class TradesNotifier extends AsyncNotifier { - OpenOrdersRepository? _repository; - StreamSubscription>? _subscription; - - - @override - FutureOr build() async { - state = const AsyncLoading(); - - _repository = ref.watch(orderRepositoryProvider); - _repository!.subscribeToOrders(); - - _subscription = _repository!.eventsStream.listen((orders) { - final filteredOrders = _filterOrders(orders); - state = AsyncData( - TradesState( - filteredOrders, - ), - ); - }, onError: (error) { - state = AsyncError(error, StackTrace.current); - }); - - ref.onDispose(() { - _subscription?.cancel(); - }); - - return TradesState([]); - } - - /// Refreshes the data by re-initializing the notifier. - Future refresh() async { - _subscription?.cancel(); - await build(); - } - - List _filterOrders(List orders) { - final currentState = state.value; - if (currentState == null) return []; - - final sessionManager = ref.watch(sessionManagerProvider); - final orderIds = sessionManager.sessions.map((s) => s.orderId); - - return orders - .where((order) => orderIds.contains(order.orderId)) - .toList(); - } - -} diff --git a/lib/features/trades/notifiers/trades_state.dart b/lib/features/trades/notifiers/trades_state.dart deleted file mode 100644 index 9fbf4321..00000000 --- a/lib/features/trades/notifiers/trades_state.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:dart_nostr/dart_nostr.dart'; - -class TradesState { - final List orders; - - TradesState(this.orders); -} diff --git a/lib/features/trades/providers/trades_provider.dart b/lib/features/trades/providers/trades_provider.dart index 62168d1c..a1889b05 100644 --- a/lib/features/trades/providers/trades_provider.dart +++ b/lib/features/trades/providers/trades_provider.dart @@ -1,7 +1,26 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/trades/notifiers/trades_notifier.dart'; -import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; -final tradesProvider = AsyncNotifierProvider( - TradesNotifier.new, -); +final filteredTradesProvider = Provider>((ref) { + final allOrdersAsync = ref.watch(orderEventsProvider); + final sessionManager = ref.watch(sessionManagerProvider); + + return allOrdersAsync.maybeWhen( + data: (allOrders) { + final orderIds = sessionManager.sessions.map((s) => s.orderId).toSet(); + + allOrders + .sort((o1, o2) => o1.expirationDate.compareTo(o2.expirationDate)); + + final filtered = allOrders.reversed + .where((o) => orderIds.contains(o.orderId)) + .where((o) => o.status != 'canceled') + .toList(); + return filtered; + }, + orElse: () => [], + ); +}); diff --git a/lib/features/trades/screens/trades_screen.dart b/lib/features/trades/screens/trades_screen.dart index 7faa1cb4..58c03a21 100644 --- a/lib/features/trades/screens/trades_screen.dart +++ b/lib/features/trades/screens/trades_screen.dart @@ -1,9 +1,9 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/shared/widgets/order_filter.dart'; -import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; import 'package:mostro_mobile/features/trades/providers/trades_provider.dart'; import 'package:mostro_mobile/features/trades/widgets/trades_list.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; @@ -15,63 +15,43 @@ class TradesScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final tradesAsync = ref.watch(tradesProvider); - final provider = ref.watch(tradesProvider.notifier); + final state = ref.watch(filteredTradesProvider); - return tradesAsync.when( - data: (state) { - return Scaffold( - backgroundColor: AppTheme.dark1, - appBar: const MostroAppBar(), - drawer: const MostroAppDrawer(), - body: RefreshIndicator( - onRefresh: () async { - await provider.refresh(); - }, - child: Container( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), - decoration: BoxDecoration( - color: AppTheme.dark2, - borderRadius: BorderRadius.circular(20), + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: const MostroAppBar(), + drawer: const MostroAppDrawer(), + body: RefreshIndicator( + onRefresh: () async {}, + child: Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), + decoration: BoxDecoration( + color: AppTheme.dark2, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'My Trades', + style: AppTheme.theme.textTheme.displayLarge, + ), ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'My Trades', - style: AppTheme.theme.textTheme.displayLarge, - ), - ), - _buildFilterButton(context, state), - const SizedBox(height: 6.0), - Expanded( - child: _buildOrderList(state), - ), - const BottomNavBar(), - ], + _buildFilterButton(context, state), + const SizedBox(height: 6.0), + Expanded( + child: _buildOrderList(state), ), - ), - ), - ); - }, - loading: () => const Scaffold( - backgroundColor: AppTheme.dark1, - body: Center(child: CircularProgressIndicator()), - ), - error: (error, stack) => Scaffold( - backgroundColor: AppTheme.dark1, - body: Center( - child: Text( - 'Error: $error', - style: const TextStyle(color: AppTheme.cream1), + const BottomNavBar(), + ], ), ), ), ); } - Widget _buildFilterButton(BuildContext context, TradesState homeState) { + Widget _buildFilterButton(BuildContext context, List trades) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( @@ -102,7 +82,7 @@ class TradesScreen extends ConsumerWidget { ), const SizedBox(width: 8), Text( - "${homeState.orders.length} trades", + "${trades.length} trades", style: const TextStyle(color: AppTheme.cream1), ), ], @@ -110,8 +90,8 @@ class TradesScreen extends ConsumerWidget { ); } - Widget _buildOrderList(TradesState state) { - if (state.orders.isEmpty) { + Widget _buildOrderList(List trades) { + if (trades.isEmpty) { return const Center( child: Text( 'No trades available for this type', @@ -120,6 +100,6 @@ class TradesScreen extends ConsumerWidget { ); } - return TradesList(state: state); + return TradesList(trades: trades); } } diff --git a/lib/features/trades/widgets/trades_list.dart b/lib/features/trades/widgets/trades_list.dart index f0d89342..c50a41ec 100644 --- a/lib/features/trades/widgets/trades_list.dart +++ b/lib/features/trades/widgets/trades_list.dart @@ -1,18 +1,18 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; -import 'package:mostro_mobile/features/trades/notifiers/trades_state.dart'; import 'package:mostro_mobile/features/trades/widgets/trades_list_item.dart'; class TradesList extends StatelessWidget { - final TradesState state; + final List trades; - const TradesList({super.key, required this.state}); + const TradesList({super.key, required this.trades}); @override Widget build(BuildContext context) { return ListView.builder( - itemCount: state.orders.length, + itemCount: trades.length, itemBuilder: (context, index) { - return TradesListItem(trade: state.orders[index]); + return TradesListItem(trade: trades[index]); }, ); } diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 875397b5..7cc31506 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -13,8 +13,8 @@ import 'package:shared_preferences/shared_preferences.dart'; final appInitializerProvider = FutureProvider((ref) async { final settings = ref.read(settingsProvider); - final service = ref.read(nostrServiceProvider); - await service.updateSettings(settings); + final nostrService = ref.read(nostrServiceProvider); + await nostrService.updateSettings(settings); final keyManager = ref.read(keyManagerProvider); bool hasMaster = await keyManager.hasMasterKey(); @@ -23,11 +23,12 @@ final appInitializerProvider = FutureProvider((ref) async { } final sessionManager = ref.read(sessionManagerProvider); + sessionManager.updateSettings(settings); await sessionManager.init(); ref.listen(settingsProvider, (previous, next) { - service.updateSettings(next); - sessionManager.fullPrivacyMode = next.fullPrivacyMode; + nostrService.updateSettings(next); + sessionManager.updateSettings(next); }); final mostroRepository = ref.read(mostroRepositoryProvider); diff --git a/lib/shared/providers/exchange_service_provider.dart b/lib/shared/providers/exchange_service_provider.dart index bb025653..ffae9f31 100644 --- a/lib/shared/providers/exchange_service_provider.dart +++ b/lib/shared/providers/exchange_service_provider.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; +import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/currency.dart'; import 'package:mostro_mobile/services/exchange_service.dart'; import 'package:mostro_mobile/services/yadio_exchange_service.dart'; @@ -6,17 +9,23 @@ final exchangeServiceProvider = Provider((ref) { return YadioExchangeService(); }); -final exchangeRateProvider = StateNotifierProvider.family, String>((ref, currency) { +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 currencyCodesProvider = + FutureProvider>((ref) async { + final raw = await rootBundle.loadString('assets/data/fiat.json'); + final jsonMap = json.decode(raw) as Map; + final Map currencies = + jsonMap.map((key, value) => MapEntry(key, Currency.fromJson(value))); + currencies.removeWhere((k, v) => !v.price); + return currencies; }); final selectedFiatCodeProvider = StateProvider((ref) => null); + diff --git a/lib/shared/widgets/currency_combo_box.dart b/lib/shared/widgets/currency_combo_box.dart index 50370b51..8039a947 100644 --- a/lib/shared/widgets/currency_combo_box.dart +++ b/lib/shared/widgets/currency_combo_box.dart @@ -43,7 +43,7 @@ class CurrencyComboBox extends ConsumerWidget { ), data: (currencyCodes) { // Create a list of string labels like "USD - United States Dollar" - final entries = currencyCodes.entries.map((e) => '${e.key} - ${e.value}').toList(); + final entries = currencyCodes.entries.map((e) => '${e.key} - ${e.value.name}').toList(); return Autocomplete( optionsBuilder: (TextEditingValue textEditingValue) { @@ -71,7 +71,7 @@ class CurrencyComboBox extends ConsumerWidget { if (selectedFiatCode != null) { final existingLabel = currencyCodes[selectedFiatCode]; if (existingLabel != null) { - textEditingController.text = '$selectedFiatCode - $existingLabel'; + textEditingController.text = '$selectedFiatCode - ${existingLabel.name}'; } } diff --git a/pubspec.yaml b/pubspec.yaml index aff91bc7..82e40a44 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -109,6 +109,7 @@ flutter: assets: - assets/ - assets/images/ + - assets/data/fiat.json # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images From db82cd2c98c31be3a15ef9d69ebadfa5afe27938 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Mon, 24 Feb 2025 17:08:49 -0800 Subject: [PATCH 067/149] fix for build action --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b634ad85..2f81a530 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -82,7 +82,7 @@ jobs: #12 Modify Tag if it Exists - name: Modify Tag - if: env.TAG_EXISTS == 'true' + if: ${{ env.TAG_EXISTS }} == 'true' id: modify_tag run: | new_version="${{ env.VERSION }}-alpha-${{ github.run_number }}" From 753c9fc14eba84146c2c572eb4dba97e8c4818c5 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 26 Feb 2025 01:22:37 -0800 Subject: [PATCH 068/149] works in full privacy mode --- lib/core/config.dart | 4 +-- lib/data/models/mostro_message.dart | 4 +-- lib/data/repositories/mostro_repository.dart | 3 +-- .../repositories/open_orders_repository.dart | 13 ++++++++-- .../order/notfiers/add_order_notifier.dart | 12 +++++++-- lib/features/settings/settings_screen.dart | 3 +++ lib/services/mostro_service.dart | 25 +++++++++++-------- lib/shared/providers/app_init_provider.dart | 4 +++ lib/shared/utils/nostr_utils.dart | 9 +++++++ lib/shared/widgets/mostro_app_bar.dart | 2 -- 10 files changed, 57 insertions(+), 22 deletions(-) diff --git a/lib/core/config.dart b/lib/core/config.dart index 82b2a807..72e32552 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -11,8 +11,8 @@ class Config { // hexkey de Mostro static const String mostroPubKey = - '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; - // '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + //'9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; static const String dBName = 'mostro.db'; static const String dBPassword = 'mostro'; diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index ac426e60..5312996d 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -5,7 +5,7 @@ import 'package:mostro_mobile/data/models/payload.dart'; class MostroMessage { String? id; - String? requestId; + int? requestId; final Action action; int? tradeIndex; T? _payload; @@ -27,7 +27,7 @@ class MostroMessage { factory MostroMessage.fromJson(Map json) { return MostroMessage( action: Action.fromString(json['action']), - requestId: json['id'], + requestId: json['request_id'], tradeIndex: json['trade_index'], id: json['id'], payload: json['payload'] != null diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index 3604c669..46a520a0 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -68,7 +68,6 @@ class MostroRepository implements OrderRepository { } Future> publishOrder(MostroMessage order) async { - _logger.i(order); final session = await _mostroService.publishOrder(order); return _subscribe(session); } @@ -79,7 +78,7 @@ class MostroRepository implements OrderRepository { Future saveMessages() async { //for (var m in _messages.values.toList()) { - //await _messageStorage.addOrder(m); + //await _messageStorage.addOrder(m); //} } diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 15a94085..b5decd2c 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -5,6 +5,7 @@ import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; const orderEventKind = 38383; @@ -20,6 +21,8 @@ class OpenOrdersRepository implements OrderRepository { final _logger = Logger(); StreamSubscription? _subscription; + String mostroPubKey = Config.mostroPubKey; + NostrEvent? get mostroInstance => _mostroInstance; OpenOrdersRepository(this._nostrService); @@ -32,7 +35,7 @@ class OpenOrdersRepository implements OrderRepository { DateTime.now().subtract(Duration(hours: orderFilterDurationHours)); var filter = NostrFilter( kinds: const [orderEventKind], - authors: [Config.mostroPubKey], + authors: [mostroPubKey], since: filterTime, ); @@ -40,7 +43,7 @@ class OpenOrdersRepository implements OrderRepository { if (event.type == 'order') { _events[event.orderId!] = event; _eventStreamController.add(_events.values.toList()); - } else if (event.type == 'info' && event.pubkey == Config.mostroPubKey) { + } else if (event.type == 'info' && event.pubkey == mostroPubKey) { _logger.i('Mostro instance info loaded: $event'); _mostroInstance = event; } @@ -88,4 +91,10 @@ class OpenOrdersRepository implements OrderRepository { // TODO: implement updateOrder throw UnimplementedError(); } + + void updateSettings(Settings settings) { + mostroPubKey = settings.mostroInstance; + _events.clear(); + subscribeToOrders(); + } } diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart index faff0e97..628da48d 100644 --- a/lib/features/order/notfiers/add_order_notifier.dart +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -29,13 +29,21 @@ class AddOrderNotifier extends AbstractOrderNotifier { final confirmedOrderId = confirmedOrder.id; final newNotifier = ref.read(orderNotifierProvider(confirmedOrderId!).notifier); + handleOrderUpdate(); newNotifier.reSubscribe(); dispose(); } Future submitOrder(Order order) async { - final message = - MostroMessage(action: Action.newOrder, id: null, payload: order); + final requestId = BigInt.parse(orderId.replaceAll('-', ''), radix: 16) + .toUnsigned(64) + .toInt(); + + final message = MostroMessage( + action: Action.newOrder, + id: null, + requestId: requestId, + payload: order); final stream = await orderRepository.publishOrder(message); await subscribe(stream); } diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 1a72447e..a724fcf0 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -105,6 +105,9 @@ class SettingsScreen extends ConsumerWidget { key: key, controller: mostroTextContoller, style: const TextStyle(color: AppTheme.cream1), + onChanged: (value) => ref + .watch(settingsProvider.notifier) + .updateMostroInstanceSetting(value), decoration: InputDecoration( border: InputBorder.none, labelText: 'Mostro Pubkey', diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 602583ad..bd5a5654 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -9,6 +9,7 @@ import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/session_manager.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; @@ -16,6 +17,7 @@ class MostroService { final NostrService _nostrService; final SessionManager _sessionManager; final _logger = Logger(); + String mostroPubKey = Config.mostroPubKey; MostroService(this._nostrService, this._sessionManager); @@ -88,7 +90,7 @@ class MostroService { final content = newMessage(Action.takeSell, orderId, payload: order); _logger.i(content); - await sendMessage(orderId, Config.mostroPubKey, content); + await sendMessage(orderId, mostroPubKey, content); return session; } @@ -100,7 +102,7 @@ class MostroService { amount, ] }); - await sendMessage(orderId, Config.mostroPubKey, content); + await sendMessage(orderId, mostroPubKey, content); } Future takeBuyOrder(String orderId, int? amount) async { @@ -108,7 +110,7 @@ class MostroService { final amt = amount != null ? {'amount': amount} : null; final content = newMessage(Action.takeBuy, orderId, payload: amt); _logger.i(content); - await sendMessage(orderId, Config.mostroPubKey, content); + await sendMessage(orderId, mostroPubKey, content); return session; } @@ -122,8 +124,8 @@ class MostroService { final bytes = utf8.encode(serializedEvent); final digest = sha256.convert(bytes); final hash = hex.encode(digest.bytes); - final signature = session.masterKey.sign(hash); - content = jsonEncode([message, signature]); + final signature = session.tradeKey.sign(hash); + content = jsonEncode([serializedEvent, signature]); } else { content = jsonEncode([ {'order': order.toJson()}, @@ -131,25 +133,24 @@ class MostroService { ]); } _logger.i('Publishing order: $content'); - final event = await createNIP59Event(content, Config.mostroPubKey, session); + final event = await createNIP59Event(content, mostroPubKey, session); await _nostrService.publishEvent(event); - _logger.i('Publishing order: $event'); return session; } Future cancelOrder(String orderId) async { final content = newMessage(Action.cancel, orderId); - await sendMessage(orderId, Config.mostroPubKey, content); + await sendMessage(orderId, mostroPubKey, content); } Future sendFiatSent(String orderId) async { final content = newMessage(Action.fiatSent, orderId); - await sendMessage(orderId, Config.mostroPubKey, content); + await sendMessage(orderId, mostroPubKey, content); } Future releaseOrder(String orderId) async { final content = newMessage(Action.release, orderId); - await sendMessage(orderId, Config.mostroPubKey, content); + await sendMessage(orderId, mostroPubKey, content); } Map newMessage(Action actionType, String orderId, @@ -207,4 +208,8 @@ class MostroService { return wrapEvent; } + + void updateSettings(Settings settings) { + mostroPubKey = settings.mostroInstance; + } } diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 7cc31506..ed623a67 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -8,6 +8,7 @@ import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -26,9 +27,12 @@ final appInitializerProvider = FutureProvider((ref) async { sessionManager.updateSettings(settings); await sessionManager.init(); + ref.listen(settingsProvider, (previous, next) { nostrService.updateSettings(next); sessionManager.updateSettings(next); + ref.read(orderRepositoryProvider).updateSettings(next); + ref.read(mostroServiceProvider).updateSettings(next); }); final mostroRepository = ref.read(mostroRepositoryProvider); diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index a89c1ee8..60b4dc2e 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -3,11 +3,14 @@ import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:elliptic/elliptic.dart'; +import 'package:logger/logger.dart'; import 'package:nip44/nip44.dart'; class NostrUtils { static final Nostr _instance = Nostr.instance; + static final Logger _logger = Logger(); + // Generación de claves static NostrKeyPairs generateKeyPair() { try { @@ -156,6 +159,8 @@ class NostrUtils { ], ); + _logger.i('Rumor event: ${rumorEvent.toMap()}'); + try { return await _encryptNIP44( jsonEncode(rumorEvent.toMap()), wrapperKey, recipientPubKey); @@ -176,6 +181,8 @@ class NostrUtils { createdAt: randomNow(), ); + _logger.i('Seal event: ${sealEvent.toMap()}'); + return await _encryptNIP44( jsonEncode(sealEvent.toMap()), wrapperKey, recipientPubKey); } @@ -192,6 +199,8 @@ class NostrUtils { createdAt: DateTime.now(), ); + _logger.i('Wrap event: ${wrapEvent.toMap()}'); + return wrapEvent; } diff --git a/lib/shared/widgets/mostro_app_bar.dart b/lib/shared/widgets/mostro_app_bar.dart index b2ed3d38..b5214c00 100644 --- a/lib/shared/widgets/mostro_app_bar.dart +++ b/lib/shared/widgets/mostro_app_bar.dart @@ -34,8 +34,6 @@ class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { icon: const HeroIcon(HeroIcons.bolt, style: HeroIconStyle.solid, color: AppTheme.yellow), onPressed: () async { - final mostroStorage = ref.watch(mostroStorageProvider); - await clearAppData(mostroStorage); }, ), ], From 6f7c62c67cfab4bcb97392e69fc5ae86d8f23895 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 26 Feb 2025 12:06:25 -0800 Subject: [PATCH 069/149] refactored app settings to be more reactive --- lib/core/config.dart | 2 ++ lib/data/models/mostro_message.dart | 7 +++++-- .../repositories/open_orders_repository.dart | 20 +++++++++++------- lib/data/repositories/session_manager.dart | 7 ++++--- .../home/providers/home_order_providers.dart | 2 +- .../providers/order_notifier_provider.dart | 4 ++-- lib/features/settings/settings.dart | 12 +++++------ lib/features/settings/settings_notifier.dart | 4 ++-- lib/features/settings/settings_screen.dart | 2 +- .../trades/providers/trades_provider.dart | 2 +- lib/services/mostro_service.dart | 21 ++++++++++--------- lib/services/nostr_service.dart | 15 ++++++------- lib/shared/providers/app_init_provider.dart | 19 +---------------- .../providers/mostro_service_provider.dart | 16 ++++++++++++-- .../providers/nostr_service_provider.dart | 11 +++++++++- .../providers/order_repository_provider.dart | 11 +++++++++- .../providers/session_manager_provider.dart | 13 +++++++++++- 17 files changed, 100 insertions(+), 68 deletions(-) diff --git a/lib/core/config.dart b/lib/core/config.dart index 72e32552..9b8f9a22 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -20,6 +20,8 @@ class Config { // Tiempo de espera para conexiones a relays static const Duration nostrConnectionTimeout = Duration(seconds: 30); + static bool fullPrivacyMode = false; + // Modo de depuración static bool get isDebug => !kReleaseMode; diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 5312996d..aab12826 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -14,14 +14,17 @@ class MostroMessage { : _payload = payload; Map toJson() { - return { + final json = { 'version': Config.mostroVersion, 'request_id': requestId, 'trade_index': tradeIndex, - 'id': id, 'action': action.value, 'payload': _payload?.toJson(), }; + if (id != null) { + json['id'] = id; + } + return json; } factory MostroMessage.fromJson(Map json) { diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index b5decd2c..7a1be8e6 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:logger/logger.dart'; -import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; @@ -14,6 +13,8 @@ const orderFilterDurationHours = 48; class OpenOrdersRepository implements OrderRepository { final NostrService _nostrService; NostrEvent? _mostroInstance; + Settings _settings; + String mostroPubKey = ''; final StreamController> _eventStreamController = StreamController.broadcast(); @@ -21,11 +22,11 @@ class OpenOrdersRepository implements OrderRepository { final _logger = Logger(); StreamSubscription? _subscription; - String mostroPubKey = Config.mostroPubKey; - NostrEvent? get mostroInstance => _mostroInstance; - OpenOrdersRepository(this._nostrService); + OpenOrdersRepository(this._nostrService, this._settings) { + mostroPubKey = _settings.mostroPublicKey; + } /// Subscribes to events matching the given filter. void subscribeToOrders() { @@ -93,8 +94,13 @@ class OpenOrdersRepository implements OrderRepository { } void updateSettings(Settings settings) { - mostroPubKey = settings.mostroInstance; - _events.clear(); - subscribeToOrders(); + if (_settings.mostroPublicKey != settings.mostroPublicKey) { + _logger.i('Mostro instance changed, updating...'); + _settings = settings.copyWith(); + _events.clear(); + subscribeToOrders(); + } else { + _settings = settings.copyWith(); + } } } diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index fc19d75c..b91568d6 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -10,7 +10,7 @@ class SessionManager { final KeyManager _keyManager; final SessionStorage _sessionStorage; - bool fullPrivacyMode = true; + Settings _settings; // In-memory session cache final Map _sessions = {}; @@ -26,6 +26,7 @@ class SessionManager { SessionManager( this._keyManager, this._sessionStorage, + this._settings ) { _initializeCleanup(); } @@ -39,7 +40,7 @@ class SessionManager { } void updateSettings(Settings settings) { - fullPrivacyMode = settings.fullPrivacyMode; + _settings = settings.copyWith(); } /// Creates a new session, storing it both in memory and in the database. @@ -53,7 +54,7 @@ class SessionManager { masterKey: masterKey, keyIndex: keyIndex, tradeKey: tradeKey, - fullPrivacy: fullPrivacyMode, + fullPrivacy: _settings.fullPrivacyMode, orderId: orderId, ); diff --git a/lib/features/home/providers/home_order_providers.dart b/lib/features/home/providers/home_order_providers.dart index ceaef7d4..6ba5999e 100644 --- a/lib/features/home/providers/home_order_providers.dart +++ b/lib/features/home/providers/home_order_providers.dart @@ -11,7 +11,7 @@ final homeOrderTypeProvider = StateProvider((ref) => OrderType.sell); final filteredOrdersProvider = Provider>((ref) { final allOrdersAsync = ref.watch(orderEventsProvider); final orderType = ref.watch(homeOrderTypeProvider); - final sessionManager = ref.watch(sessionManagerProvider); + final sessionManager = ref.read(sessionManagerProvider); return allOrdersAsync.maybeWhen( data: (allOrders) { diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index e1e1a191..dd8e7d71 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -8,7 +8,7 @@ import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; final orderNotifierProvider = StateNotifierProvider.family( (ref, orderId,) { - final repo = ref.watch(mostroRepositoryProvider); + final repo = ref.read(mostroRepositoryProvider); return OrderNotifier( repo, orderId, @@ -20,7 +20,7 @@ final orderNotifierProvider = final addOrderNotifierProvider = StateNotifierProvider.family( (ref, orderId) { - final repo = ref.watch(mostroRepositoryProvider); + final repo = ref.read(mostroRepositoryProvider); return AddOrderNotifier(repo, orderId, ref); }, ); diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart index 6a23a9f8..1fed1d17 100644 --- a/lib/features/settings/settings.dart +++ b/lib/features/settings/settings.dart @@ -1,15 +1,13 @@ -import 'package:mostro_mobile/core/config.dart'; - class Settings { final bool fullPrivacyMode; final List relays; - final String mostroInstance; + final String mostroPublicKey; final String? defaultFiatCode; Settings( {required this.relays, required this.fullPrivacyMode, - required this.mostroInstance, + required this.mostroPublicKey, this.defaultFiatCode}); Settings copyWith( @@ -20,7 +18,7 @@ class Settings { return Settings( relays: relays ?? this.relays, fullPrivacyMode: privacyModeSetting ?? fullPrivacyMode, - mostroInstance: mostroInstance ?? this.mostroInstance, + mostroPublicKey: mostroInstance ?? mostroPublicKey, defaultFiatCode: defaultFiatCode ?? this.defaultFiatCode, ); } @@ -28,7 +26,7 @@ class Settings { Map toJson() => { 'relays': relays, 'fullPrivacyMode': fullPrivacyMode, - 'mostroInstance': mostroInstance, + 'mostroPublicKey': mostroPublicKey, 'defaultFiatCode': defaultFiatCode, }; @@ -36,7 +34,7 @@ class Settings { return Settings( relays: (json['relays'] as List?)?.cast() ?? [], fullPrivacyMode: json['fullPrivacyMode'] as bool, - mostroInstance: json['mostroInstance'] ?? Config.mostroPubKey, + mostroPublicKey: json['mostroPublicKey'] ?? json['mostroInstance'], defaultFiatCode: json['defaultFiatCode'], ); } diff --git a/lib/features/settings/settings_notifier.dart b/lib/features/settings/settings_notifier.dart index 932ca885..e055f95c 100644 --- a/lib/features/settings/settings_notifier.dart +++ b/lib/features/settings/settings_notifier.dart @@ -16,8 +16,8 @@ class SettingsNotifier extends StateNotifier { static Settings _defaultSettings() { return Settings( relays: Config.nostrRelays, - fullPrivacyMode: false, - mostroInstance: Config.mostroPubKey, + fullPrivacyMode: Config.fullPrivacyMode, + mostroPublicKey: Config.mostroPubKey, ); } diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index a724fcf0..bb77ec20 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -15,7 +15,7 @@ class SettingsScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); final mostroTextContoller = - TextEditingController(text: settings.mostroInstance); + TextEditingController(text: settings.mostroPublicKey); final textTheme = AppTheme.theme.textTheme; return Scaffold( diff --git a/lib/features/trades/providers/trades_provider.dart b/lib/features/trades/providers/trades_provider.dart index a1889b05..0c93530b 100644 --- a/lib/features/trades/providers/trades_provider.dart +++ b/lib/features/trades/providers/trades_provider.dart @@ -6,7 +6,7 @@ import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; final filteredTradesProvider = Provider>((ref) { final allOrdersAsync = ref.watch(orderEventsProvider); - final sessionManager = ref.watch(sessionManagerProvider); + final sessionManager = ref.read(sessionManagerProvider); return allOrdersAsync.maybeWhen( data: (allOrders) { diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index bd5a5654..9df55eb5 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -17,9 +17,9 @@ class MostroService { final NostrService _nostrService; final SessionManager _sessionManager; final _logger = Logger(); - String mostroPubKey = Config.mostroPubKey; + Settings settings; - MostroService(this._nostrService, this._sessionManager); + MostroService(this._nostrService, this._sessionManager, this.settings); Stream subscribe(Session session) { final filter = NostrFilter(p: [session.tradeKey.public]); @@ -90,7 +90,7 @@ class MostroService { final content = newMessage(Action.takeSell, orderId, payload: order); _logger.i(content); - await sendMessage(orderId, mostroPubKey, content); + await sendMessage(orderId, settings.mostroPublicKey, content); return session; } @@ -102,7 +102,7 @@ class MostroService { amount, ] }); - await sendMessage(orderId, mostroPubKey, content); + await sendMessage(orderId, settings.mostroPublicKey, content); } Future takeBuyOrder(String orderId, int? amount) async { @@ -110,7 +110,7 @@ class MostroService { final amt = amount != null ? {'amount': amount} : null; final content = newMessage(Action.takeBuy, orderId, payload: amt); _logger.i(content); - await sendMessage(orderId, mostroPubKey, content); + await sendMessage(orderId, settings.mostroPublicKey, content); return session; } @@ -133,24 +133,25 @@ class MostroService { ]); } _logger.i('Publishing order: $content'); - final event = await createNIP59Event(content, mostroPubKey, session); + final event = + await createNIP59Event(content, settings.mostroPublicKey, session); await _nostrService.publishEvent(event); return session; } Future cancelOrder(String orderId) async { final content = newMessage(Action.cancel, orderId); - await sendMessage(orderId, mostroPubKey, content); + await sendMessage(orderId, settings.mostroPublicKey, content); } Future sendFiatSent(String orderId) async { final content = newMessage(Action.fiatSent, orderId); - await sendMessage(orderId, mostroPubKey, content); + await sendMessage(orderId, settings.mostroPublicKey, content); } Future releaseOrder(String orderId) async { final content = newMessage(Action.release, orderId); - await sendMessage(orderId, mostroPubKey, content); + await sendMessage(orderId, settings.mostroPublicKey, content); } Map newMessage(Action actionType, String orderId, @@ -210,6 +211,6 @@ class MostroService { } void updateSettings(Settings settings) { - mostroPubKey = settings.mostroInstance; + this.settings = settings.copyWith(); } } diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index effa6064..82aff34b 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -7,21 +7,18 @@ import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class NostrService { - Settings? settings; - static final NostrService _instance = NostrService._internal(); - factory NostrService() => _instance; - NostrService._internal(); + Settings settings; + final Nostr _nostr = Nostr.instance; + + NostrService(this.settings); final Logger _logger = Logger(); - late Nostr _nostr; bool _isInitialized = false; Future init() async { - //if (_isInitialized) return; - _nostr = Nostr.instance; try { await _nostr.services.relays.init( - relaysUrl: settings!.relays, + relaysUrl: settings.relays, connectionTimeout: Config.nostrConnectionTimeout, shouldReconnectToRelayOnNotice: true, onRelayListening: (relay, url, channel) { @@ -47,7 +44,7 @@ class NostrService { Future updateSettings(Settings newSettings) async { settings = newSettings.copyWith(); final relays = Nostr.instance.services.relays.relaysList; - if (!ListEquality().equals(relays, settings?.relays) ) { + if (!ListEquality().equals(relays, settings.relays) ) { _logger.i('Updating relays...'); await init(); } diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index ed623a67..3a540ce9 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -4,18 +4,13 @@ import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; -import 'package:mostro_mobile/features/settings/settings.dart'; -import 'package:mostro_mobile/features/settings/settings_provider.dart'; -import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; -import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; final appInitializerProvider = FutureProvider((ref) async { - final settings = ref.read(settingsProvider); final nostrService = ref.read(nostrServiceProvider); - await nostrService.updateSettings(settings); + await nostrService.init(); final keyManager = ref.read(keyManagerProvider); bool hasMaster = await keyManager.hasMasterKey(); @@ -24,20 +19,8 @@ final appInitializerProvider = FutureProvider((ref) async { } final sessionManager = ref.read(sessionManagerProvider); - sessionManager.updateSettings(settings); await sessionManager.init(); - - ref.listen(settingsProvider, (previous, next) { - nostrService.updateSettings(next); - sessionManager.updateSettings(next); - ref.read(orderRepositoryProvider).updateSettings(next); - ref.read(mostroServiceProvider).updateSettings(next); - }); - - final mostroRepository = ref.read(mostroRepositoryProvider); - await mostroRepository.loadMessages(); - for (final session in sessionManager.sessions) { if (session.orderId != null) { final order = ref.watch(orderNotifierProvider(session.orderId!).notifier); diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index bfc9272a..8c3ac41e 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -1,5 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; @@ -8,11 +10,21 @@ import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; final mostroServiceProvider = Provider((ref) { final sessionStorage = ref.watch(sessionManagerProvider); final nostrService = ref.watch(nostrServiceProvider); - return MostroService(nostrService, sessionStorage); + final settings = ref.watch(settingsProvider); + final mostroService = MostroService(nostrService, sessionStorage, settings); + + ref.listen(settingsProvider, (previous, next) { + mostroService.updateSettings(next); + }); + + return mostroService; }); final mostroRepositoryProvider = Provider((ref) { final mostroService = ref.watch(mostroServiceProvider); final mostroDatabase = ref.watch(mostroStorageProvider); - return MostroRepository(mostroService, mostroDatabase); + + final mostroRepository = MostroRepository(mostroService, mostroDatabase); + + return mostroRepository; }); diff --git a/lib/shared/providers/nostr_service_provider.dart b/lib/shared/providers/nostr_service_provider.dart index 94b605a5..f44fb50a 100644 --- a/lib/shared/providers/nostr_service_provider.dart +++ b/lib/shared/providers/nostr_service_provider.dart @@ -1,6 +1,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; final nostrServiceProvider = Provider((ref) { - return NostrService(); + final settings = ref.read(settingsProvider); + final nostrService = NostrService(settings); + + ref.listen(settingsProvider, (previous, next) { + nostrService.updateSettings(next); + }); + + return nostrService; }); diff --git a/lib/shared/providers/order_repository_provider.dart b/lib/shared/providers/order_repository_provider.dart index cee4e4ad..5493d630 100644 --- a/lib/shared/providers/order_repository_provider.dart +++ b/lib/shared/providers/order_repository_provider.dart @@ -1,11 +1,20 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; final orderRepositoryProvider = Provider((ref) { final nostrService = ref.read(nostrServiceProvider); - return OpenOrdersRepository(nostrService); + final settings = ref.watch(settingsProvider); + final orderRepo = OpenOrdersRepository(nostrService, settings); + + ref.listen(settingsProvider, (previous, next) { + orderRepo.updateSettings(next); + }); + + return orderRepo; }); final orderEventsProvider = StreamProvider>((ref) { diff --git a/lib/shared/providers/session_manager_provider.dart b/lib/shared/providers/session_manager_provider.dart index f6f4a59d..b5b4fcf7 100644 --- a/lib/shared/providers/session_manager_provider.dart +++ b/lib/shared/providers/session_manager_provider.dart @@ -1,10 +1,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/session_storage_provider.dart'; final sessionManagerProvider = Provider((ref) { final keyManager = ref.read(keyManagerProvider); final sessionStorage = ref.read(sessionStorageProvider); - return SessionManager(keyManager, sessionStorage); + final settings = ref.read(settingsProvider); + + final sessionManager = + SessionManager(keyManager, sessionStorage, settings.copyWith(), ); + + ref.listen(settingsProvider, (previous, next) { + sessionManager.updateSettings(next); + }); + + return sessionManager; }); From 66c28ce5b6f27ed89d294ad867a393baea55c5c1 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 26 Feb 2025 13:12:23 -0800 Subject: [PATCH 070/149] More settings refactoring --- lib/core/config.dart | 2 +- .../repositories/open_orders_repository.dart | 9 ++-- lib/features/mostro/mostro_screen.dart | 4 +- lib/features/settings/about_screen.dart | 50 +++++++++---------- lib/shared/providers/app_init_provider.dart | 1 + .../providers/mostro_service_provider.dart | 10 ++-- .../providers/order_repository_provider.dart | 6 +-- 7 files changed, 39 insertions(+), 43 deletions(-) diff --git a/lib/core/config.dart b/lib/core/config.dart index 9b8f9a22..bb105062 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -5,7 +5,7 @@ class Config { static const List nostrRelays = [ 'wss://relay.mostro.network', //'ws://127.0.0.1:7000', - //'ws://192.168.1.148:7000', + //'ws://192.168.1.103:7000', //'ws://10.0.2.2:7000', // mobile emulator ]; diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 7a1be8e6..de697c7e 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -14,7 +14,6 @@ class OpenOrdersRepository implements OrderRepository { final NostrService _nostrService; NostrEvent? _mostroInstance; Settings _settings; - String mostroPubKey = ''; final StreamController> _eventStreamController = StreamController.broadcast(); @@ -24,9 +23,7 @@ class OpenOrdersRepository implements OrderRepository { NostrEvent? get mostroInstance => _mostroInstance; - OpenOrdersRepository(this._nostrService, this._settings) { - mostroPubKey = _settings.mostroPublicKey; - } + OpenOrdersRepository(this._nostrService, this._settings); /// Subscribes to events matching the given filter. void subscribeToOrders() { @@ -36,7 +33,7 @@ class OpenOrdersRepository implements OrderRepository { DateTime.now().subtract(Duration(hours: orderFilterDurationHours)); var filter = NostrFilter( kinds: const [orderEventKind], - authors: [mostroPubKey], + authors: [_settings.mostroPublicKey], since: filterTime, ); @@ -44,7 +41,7 @@ class OpenOrdersRepository implements OrderRepository { if (event.type == 'order') { _events[event.orderId!] = event; _eventStreamController.add(_events.values.toList()); - } else if (event.type == 'info' && event.pubkey == mostroPubKey) { + } else if (event.type == 'info' && event.pubkey == _settings.mostroPublicKey) { _logger.i('Mostro instance info loaded: $event'); _mostroInstance = event; } diff --git a/lib/features/mostro/mostro_screen.dart b/lib/features/mostro/mostro_screen.dart index 1a2567cc..d3c59bb4 100644 --- a/lib/features/mostro/mostro_screen.dart +++ b/lib/features/mostro/mostro_screen.dart @@ -13,8 +13,8 @@ class MostroScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final nostrEvent = ref.watch(orderRepositoryProvider).mostroInstance; - final mostroMessages = ref.watch(mostroRepositoryProvider).allMessages; + final nostrEvent = ref.read(orderRepositoryProvider).mostroInstance; + final mostroMessages = ref.read(mostroRepositoryProvider).allMessages; return nostrEvent == null ? Scaffold( diff --git a/lib/features/settings/about_screen.dart b/lib/features/settings/about_screen.dart index 2de2ee28..74bae753 100644 --- a/lib/features/settings/about_screen.dart +++ b/lib/features/settings/about_screen.dart @@ -17,29 +17,25 @@ class AboutScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final nostrEvent = ref.watch(orderRepositoryProvider).mostroInstance; - return nostrEvent == null - ? Scaffold( - backgroundColor: AppTheme.dark1, - body: const Center(child: CircularProgressIndicator()), - ) - : Scaffold( - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: - const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), - onPressed: () => context.pop(), - ), - title: Text( - 'ABOUT', - style: TextStyle( - color: AppTheme.cream1, - ), - ), - ), - backgroundColor: AppTheme.dark1, - body: Padding( + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), + onPressed: () => context.pop(), + ), + title: Text( + 'ABOUT', + style: TextStyle( + color: AppTheme.cream1, + ), + ), + ), + backgroundColor: AppTheme.dark1, + body: nostrEvent == null + ? const Center(child: CircularProgressIndicator()) + : Padding( padding: const EdgeInsets.all(16.0), child: Container( decoration: BoxDecoration( @@ -71,7 +67,7 @@ class AboutScreen extends ConsumerWidget { ), ), ), - ); + ); } /// Builds the header displaying details from the MostroInstance. @@ -92,8 +88,10 @@ class AboutScreen extends ConsumerWidget { 'Version: ${instance.mostroVersion}', ), const SizedBox(height: 3), - // Text('Commit Hash: ${instance.commitHash}',), - // const SizedBox(height: 3), + Text( + 'Commit Hash: ${instance.commitHash}', + ), + const SizedBox(height: 3), Text( 'Max Order Amount: ${formatter.format(instance.maxOrderAmount)}', ), diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 3a540ce9..474efaa5 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -27,6 +27,7 @@ final appInitializerProvider = FutureProvider((ref) async { order.reSubscribe(); } } + }); Future clearAppData(MostroStorage mostroStorage) async { diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index 8c3ac41e..61f47947 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -8,9 +8,9 @@ import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; final mostroServiceProvider = Provider((ref) { - final sessionStorage = ref.watch(sessionManagerProvider); - final nostrService = ref.watch(nostrServiceProvider); - final settings = ref.watch(settingsProvider); + final sessionStorage = ref.read(sessionManagerProvider); + final nostrService = ref.read(nostrServiceProvider); + final settings = ref.read(settingsProvider); final mostroService = MostroService(nostrService, sessionStorage, settings); ref.listen(settingsProvider, (previous, next) { @@ -21,8 +21,8 @@ final mostroServiceProvider = Provider((ref) { }); final mostroRepositoryProvider = Provider((ref) { - final mostroService = ref.watch(mostroServiceProvider); - final mostroDatabase = ref.watch(mostroStorageProvider); + final mostroService = ref.read(mostroServiceProvider); + final mostroDatabase = ref.read(mostroStorageProvider); final mostroRepository = MostroRepository(mostroService, mostroDatabase); diff --git a/lib/shared/providers/order_repository_provider.dart b/lib/shared/providers/order_repository_provider.dart index 5493d630..ea391d49 100644 --- a/lib/shared/providers/order_repository_provider.dart +++ b/lib/shared/providers/order_repository_provider.dart @@ -7,7 +7,7 @@ import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; final orderRepositoryProvider = Provider((ref) { final nostrService = ref.read(nostrServiceProvider); - final settings = ref.watch(settingsProvider); + final settings = ref.read(settingsProvider); final orderRepo = OpenOrdersRepository(nostrService, settings); ref.listen(settingsProvider, (previous, next) { @@ -18,7 +18,7 @@ final orderRepositoryProvider = Provider((ref) { }); final orderEventsProvider = StreamProvider>((ref) { - final orderRepository = ref.watch(orderRepositoryProvider); + final orderRepository = ref.read(orderRepositoryProvider); orderRepository.subscribeToOrders(); return orderRepository.eventsStream; @@ -26,6 +26,6 @@ final orderEventsProvider = StreamProvider>((ref) { final eventProvider = FutureProvider.family((ref, orderId) { - final repository = ref.watch(orderRepositoryProvider); + final repository = ref.read(orderRepositoryProvider); return repository.getOrderById(orderId); }); From 6d4862c091728dbb5a03e4e9a6d68f2c2af0815c Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 26 Feb 2025 22:05:19 -0800 Subject: [PATCH 071/149] Modified signing function --- lib/services/mostro_service.dart | 32 ++++++++++----------- lib/shared/providers/app_init_provider.dart | 5 ++-- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 9df55eb5..b2ef8d37 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -125,12 +125,9 @@ class MostroService { final digest = sha256.convert(bytes); final hash = hex.encode(digest.bytes); final signature = session.tradeKey.sign(hash); - content = jsonEncode([serializedEvent, signature]); + content = '[$serializedEvent,"$signature"]'; } else { - content = jsonEncode([ - {'order': order.toJson()}, - null - ]); + content = '[{"order":${jsonEncode(order.toJson())}},null]'; } _logger.i('Publishing order: $content'); final event = @@ -159,8 +156,8 @@ class MostroService { return { 'order': { 'version': Config.mostroVersion, - 'trade_index': null, 'id': orderId, + 'trade_index': null, 'action': actionType.value, 'payload': payload, }, @@ -168,24 +165,25 @@ class MostroService { } Future sendMessage(String orderId, String receiverPubkey, - Map content) async { + Map message) async { try { final session = _sessionManager.getSessionByOrderId(orderId); - String finalContent; + String content; if (!session!.fullPrivacy) { - content['order']?['trade_index'] = session.keyIndex; - final sha256Digest = - sha256.convert(utf8.encode(jsonEncode(content['order']))); - final hashHex = hex.encode(sha256Digest.bytes); - final signature = session.tradeKey.sign(hashHex); - finalContent = jsonEncode([content, signature]); + message['order']?['trade_index'] = session.keyIndex; + final serializedEvent = jsonEncode(message); + final bytes = utf8.encode(serializedEvent); + final digest = sha256.convert(bytes); + final hash = hex.encode(digest.bytes); + final signature = session.tradeKey.sign(hash); + content = '[$serializedEvent,"$signature"]'; } else { - finalContent = jsonEncode([content, null]); + content = '[${jsonEncode(message)},null]'; } + _logger.i(content); final event = - await createNIP59Event(finalContent, receiverPubkey, session); + await createNIP59Event(content, receiverPubkey, session); await _nostrService.publishEvent(event); - _logger.i(finalContent); } catch (e) { // catch and throw and log and stuff _logger.e(e); diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 474efaa5..411f4742 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -23,11 +23,10 @@ final appInitializerProvider = FutureProvider((ref) async { for (final session in sessionManager.sessions) { if (session.orderId != null) { - final order = ref.watch(orderNotifierProvider(session.orderId!).notifier); - order.reSubscribe(); + //final order = ref.watch(orderNotifierProvider(session.orderId!).notifier); + //order.reSubscribe(); } } - }); Future clearAppData(MostroStorage mostroStorage) async { From cfa160bf2ad2ce9426fab4eb2b2f403b2f120018 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Tue, 4 Mar 2025 12:33:14 -0600 Subject: [PATCH 072/149] start of mostro service refactor --- lib/data/models/mostro_message.dart | 6 ++--- lib/features/home/screens/home_screen.dart | 4 ++- .../screens/pay_lightning_invoice_screen.dart | 6 ++++- lib/services/mostro_service.dart | 26 ++++++++++--------- lib/shared/utils/nostr_utils.dart | 8 ------ 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index aab12826..bec91ac9 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -14,16 +14,16 @@ class MostroMessage { : _payload = payload; Map toJson() { - final json = { + Map json = { 'version': Config.mostroVersion, 'request_id': requestId, 'trade_index': tradeIndex, - 'action': action.value, - 'payload': _payload?.toJson(), }; if (id != null) { json['id'] = id; } + json['action'] = action.value; + json['payload']= _payload?.toJson(); return json; } diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 16d69e27..9fefb538 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -23,7 +23,9 @@ class HomeScreen extends ConsumerWidget { appBar: const MostroAppBar(), drawer: const MostroAppDrawer(), body: RefreshIndicator( - onRefresh: () async {}, + onRefresh: () async { + return ref.refresh(filteredOrdersProvider); + }, child: Container( margin: const EdgeInsets.all(16), decoration: BoxDecoration( diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 7515f78e..734b9bbd 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/payment_request.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; @@ -37,8 +38,11 @@ class _PayLightningInvoiceScreenState crossAxisAlignment: CrossAxisAlignment.stretch, children: [ PayLightningInvoiceWidget( - onSubmit: () async {}, + onSubmit: () async { + context.go('/'); + }, onCancel: () async { + context.go('/'); await orderNotifier.cancelOrder(); }, lnInvoice: lnInvoice), diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index b2ef8d37..b37af4b6 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -4,9 +4,12 @@ import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/data/models/amount.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/payment_request.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; @@ -38,6 +41,8 @@ class MostroService { // Deserialize the message content: final result = jsonDecode(decryptedEvent.content!); + _logger.i('Decrypted Mostro event content: $result'); + // The result should be an array of two elements, the first being // A MostroMessage or CantDo if (result is! List) { @@ -79,18 +84,15 @@ class MostroService { Future takeSellOrder( String orderId, int? amount, String? lnAddress) async { - final session = await _sessionManager.newSession(orderId: orderId); - final order = lnAddress != null - ? { - 'payment_request': [null, lnAddress, amount] - } + final payload = lnAddress != null + ? PaymentRequest(order: null, lnInvoice: lnAddress, amount: amount) : amount != null - ? {'amount': amount} + ? Amount(amount: amount) : null; + final order = MostroMessage( + action: Action.takeSell, id: orderId, payload: payload); - final content = newMessage(Action.takeSell, orderId, payload: order); - _logger.i(content); - await sendMessage(orderId, settings.mostroPublicKey, content); + final session = await publishOrder(order); return session; } @@ -156,8 +158,9 @@ class MostroService { return { 'order': { 'version': Config.mostroVersion, - 'id': orderId, + 'request_id': null, 'trade_index': null, + 'id': orderId, 'action': actionType.value, 'payload': payload, }, @@ -181,8 +184,7 @@ class MostroService { content = '[${jsonEncode(message)},null]'; } _logger.i(content); - final event = - await createNIP59Event(content, receiverPubkey, session); + final event = await createNIP59Event(content, receiverPubkey, session); await _nostrService.publishEvent(event); } catch (e) { // catch and throw and log and stuff diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 60b4dc2e..24182c66 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -3,13 +3,11 @@ import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:elliptic/elliptic.dart'; -import 'package:logger/logger.dart'; import 'package:nip44/nip44.dart'; class NostrUtils { static final Nostr _instance = Nostr.instance; - static final Logger _logger = Logger(); // Generación de claves static NostrKeyPairs generateKeyPair() { @@ -159,8 +157,6 @@ class NostrUtils { ], ); - _logger.i('Rumor event: ${rumorEvent.toMap()}'); - try { return await _encryptNIP44( jsonEncode(rumorEvent.toMap()), wrapperKey, recipientPubKey); @@ -181,8 +177,6 @@ class NostrUtils { createdAt: randomNow(), ); - _logger.i('Seal event: ${sealEvent.toMap()}'); - return await _encryptNIP44( jsonEncode(sealEvent.toMap()), wrapperKey, recipientPubKey); } @@ -199,8 +193,6 @@ class NostrUtils { createdAt: DateTime.now(), ); - _logger.i('Wrap event: ${wrapEvent.toMap()}'); - return wrapEvent; } From 781242fef87ea77098ce0e3f6238c068d27756c4 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 13 Mar 2025 22:46:52 +1000 Subject: [PATCH 073/149] Refactored Mostro Service Updated My Trades Detail Screens Added 'Fiat Sent' and 'Release' buttons to My Trades Detail Screens Refactored Order Providers Added Mobile App Version and Git Hash to About Screen --- .github/workflows/main.yml | 6 +- lib/core/app_routes.dart | 2 +- lib/data/models/cant_do.dart | 8 +- lib/data/models/mostro_message.dart | 30 +- lib/data/models/nostr_event.dart | 3 +- lib/data/models/order.dart | 2 +- lib/data/models/payload.dart | 3 + lib/data/models/peer.dart | 2 +- lib/data/repositories/mostro_repository.dart | 8 + .../repositories/open_orders_repository.dart | 13 +- .../home/providers/home_order_providers.dart | 7 +- lib/features/home/screens/home_screen.dart | 4 +- .../home/widgets/order_list_item.dart | 12 +- .../screens/messages_list_screen.dart | 1 - .../mostro/mostro_settings_widget.dart | 17 - .../notfiers/abstract_order_notifier.dart | 4 +- .../order/notfiers/add_order_notifier.dart | 2 +- .../order/notfiers/order_notifier.dart | 10 +- .../order/screens/add_order_screen.dart | 2 +- lib/features/order/screens/order_screen.dart | 119 ----- lib/features/order/widgets/buyer_info.dart | 44 -- lib/features/order/widgets/order_app_bar.dart | 2 +- .../relays/widgets/relay_selector.dart | 5 +- lib/features/settings/about_screen.dart | 76 +++- lib/features/settings/settings_screen.dart | 9 +- .../trades/providers/trades_provider.dart | 8 +- .../trades/screens/trade_detail_screen.dart | 227 ++++++++++ .../trades/screens/trades_detail_screen.dart | 121 ------ .../trades/screens/trades_screen.dart | 8 +- .../trades/widgets/trades_list_item.dart | 108 ++++- lib/services/mostro_service.dart | 111 ++--- lib/shared/notifiers/session_notifier.dart | 41 ++ lib/shared/providers/app_init_provider.dart | 14 +- .../providers/mostro_service_provider.dart | 10 +- .../providers/order_repository_provider.dart | 2 - .../providers/session_manager_provider.dart | 25 +- lib/shared/providers/time_provider.dart | 10 + lib/shared/widgets/mostro_app_bar.dart | 4 +- test/mocks.mocks.dart | 411 ++++++++---------- 39 files changed, 773 insertions(+), 718 deletions(-) delete mode 100644 lib/features/mostro/mostro_settings_widget.dart delete mode 100644 lib/features/order/screens/order_screen.dart delete mode 100644 lib/features/order/widgets/buyer_info.dart create mode 100644 lib/features/trades/screens/trade_detail_screen.dart delete mode 100644 lib/features/trades/screens/trades_detail_screen.dart create mode 100644 lib/shared/notifiers/session_notifier.dart create mode 100644 lib/shared/providers/time_provider.dart diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2f81a530..b1269f1c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,15 +38,15 @@ jobs: #5 Building APK - name: Build APK - run: flutter build apk --release + run: flutter build apk --release --dart-define=APP_VERSION=${{ env.VERSION }} --dart-define=GIT_COMMIT=${{ github.sha }} #6 Building App Bundle (aab) - name: Build appBundle - run: flutter build appbundle + run: flutter build appbundle --release --dart-define=APP_VERSION=${{ env.VERSION }} --dart-define=GIT_COMMIT=${{ github.sha }} #7 Build IPA ( IOS Build ) - name: Build IPA - run: flutter build ipa --no-codesign + run: flutter build ipa --no-codesign --dart-define=APP_VERSION=${{ env.VERSION }} --dart-define=GIT_COMMIT=${{ github.sha }} - name: Compress Archives and IPAs run: | diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index f9c298dd..bd47043b 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -10,7 +10,7 @@ import 'package:mostro_mobile/features/key_manager/key_management_screen.dart'; import 'package:mostro_mobile/features/mostro/mostro_screen.dart'; import 'package:mostro_mobile/features/settings/about_screen.dart'; import 'package:mostro_mobile/features/settings/settings_screen.dart'; -import 'package:mostro_mobile/features/trades/screens/trades_detail_screen.dart'; +import 'package:mostro_mobile/features/trades/screens/trade_detail_screen.dart'; import 'package:mostro_mobile/features/trades/screens/trades_screen.dart'; import 'package:mostro_mobile/features/relays/relays_screen.dart'; import 'package:mostro_mobile/features/order/screens/add_lightning_invoice_screen.dart'; diff --git a/lib/data/models/cant_do.dart b/lib/data/models/cant_do.dart index 0c75ace4..80053dac 100644 --- a/lib/data/models/cant_do.dart +++ b/lib/data/models/cant_do.dart @@ -11,11 +11,13 @@ class CantDo implements Payload { @override Map toJson() { - // TODO: implement toJson - throw UnimplementedError(); + return { + type : { + 'cant-do' : cantDo, + } + }; } @override - // TODO: implement type String get type => 'cant_do'; } diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index bec91ac9..64923daf 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -1,4 +1,7 @@ import 'dart:convert'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:dart_nostr/nostr/core/key_pairs.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/payload.dart'; @@ -10,7 +13,12 @@ class MostroMessage { int? tradeIndex; T? _payload; - MostroMessage({required this.action, this.requestId, this.id, T? payload, this.tradeIndex}) + MostroMessage( + {required this.action, + this.requestId, + this.id, + T? payload, + this.tradeIndex}) : _payload = payload; Map toJson() { @@ -23,7 +31,7 @@ class MostroMessage { json['id'] = id; } json['action'] = action.value; - json['payload']= _payload?.toJson(); + json['payload'] = _payload?.toJson(); return json; } @@ -76,4 +84,22 @@ class MostroMessage { } return null; } + + String sign(NostrKeyPairs keyPair) { + final message = {'order': toJson()}; + final serializedEvent = jsonEncode(message); + final bytes = utf8.encode(serializedEvent); + final digest = sha256.convert(bytes); + final hash = hex.encode(digest.bytes); + final signature = keyPair.sign(hash); + return signature; + } + + String serialize({NostrKeyPairs? keyPair}) { + final message = {'order': toJson()}; + final serializedEvent = jsonEncode(message); + final signature = (keyPair != null) ? '"${sign(keyPair)}"' : null; + final content = '[$serializedEvent, $signature]'; + return content; + } } diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index 58c4f9ac..38ba6bdf 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -1,3 +1,4 @@ +import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/range_amount.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/rating.dart'; @@ -12,7 +13,7 @@ extension NostrEventExtensions on NostrEvent { ? OrderType.fromString(_getTagValue('k')!) : null; String? get currency => _getTagValue('f'); - String? get status => _getTagValue('s'); + Status get status => Status.fromString(_getTagValue('s')!); String? get amount => _getTagValue('amt'); RangeAmount get fiatAmount => _getAmount('fa'); List get paymentMethods => _getTagValue('pm')?.split(',') ?? []; diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart index 40a6150d..fa9ec55a 100644 --- a/lib/data/models/order.dart +++ b/lib/data/models/order.dart @@ -117,7 +117,7 @@ class Order implements Payload { return Order( id: event.orderId, kind: event.orderType!, - status: Status.fromString(event.status!), + status: event.status, amount: event.amount as int, fiatCode: event.currency!, fiatAmount: event.fiatAmount.minimum, diff --git a/lib/data/models/payload.dart b/lib/data/models/payload.dart index c2176964..418b1aa3 100644 --- a/lib/data/models/payload.dart +++ b/lib/data/models/payload.dart @@ -1,6 +1,7 @@ import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/payment_request.dart'; +import 'package:mostro_mobile/data/models/peer.dart'; abstract class Payload { String get type; @@ -13,6 +14,8 @@ abstract class Payload { return PaymentRequest.fromJson(json['payment_request']); } else if (json.containsKey('cant_do')) { return CantDo.fromJson(json); + } else if (json.containsKey('peer')) { + return Peer.fromJson(json); } throw UnsupportedError('Unknown content type'); } diff --git a/lib/data/models/peer.dart b/lib/data/models/peer.dart index 7f2ddfcf..24d91d3b 100644 --- a/lib/data/models/peer.dart +++ b/lib/data/models/peer.dart @@ -25,5 +25,5 @@ class Peer implements Payload { } @override - String get type => 'Peer'; + String get type => 'peer'; } diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index 46a520a0..e90b0e7c 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -129,4 +129,12 @@ class MostroRepository implements OrderRepository { // TODO: implement updateOrder throw UnimplementedError(); } + + Future sendFiatSent(String orderId) async { + await _mostroService.sendFiatSent(orderId); + } + + Future releaseOrder(String orderId) async { + await _mostroService.releaseOrder(orderId); + } } diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index de697c7e..e120b9eb 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -23,10 +23,12 @@ class OpenOrdersRepository implements OrderRepository { NostrEvent? get mostroInstance => _mostroInstance; - OpenOrdersRepository(this._nostrService, this._settings); + OpenOrdersRepository(this._nostrService, this._settings) { + _subscribeToOrders(); + } /// Subscribes to events matching the given filter. - void subscribeToOrders() { + void _subscribeToOrders() { _subscription?.cancel(); final filterTime = @@ -41,7 +43,8 @@ class OpenOrdersRepository implements OrderRepository { if (event.type == 'order') { _events[event.orderId!] = event; _eventStreamController.add(_events.values.toList()); - } else if (event.type == 'info' && event.pubkey == _settings.mostroPublicKey) { + } else if (event.type == 'info' && + event.pubkey == _settings.mostroPublicKey) { _logger.i('Mostro instance info loaded: $event'); _mostroInstance = event; } @@ -64,8 +67,6 @@ class OpenOrdersRepository implements OrderRepository { Stream> get eventsStream => _eventStreamController.stream; - List get currentEvents => _events.values.toList(); - @override Future addOrder(NostrEvent order) { // TODO: implement addOrder @@ -95,7 +96,7 @@ class OpenOrdersRepository implements OrderRepository { _logger.i('Mostro instance changed, updating...'); _settings = settings.copyWith(); _events.clear(); - subscribeToOrders(); + _subscribeToOrders(); } else { _settings = settings.copyWith(); } diff --git a/lib/features/home/providers/home_order_providers.dart b/lib/features/home/providers/home_order_providers.dart index 6ba5999e..89cff259 100644 --- a/lib/features/home/providers/home_order_providers.dart +++ b/lib/features/home/providers/home_order_providers.dart @@ -1,6 +1,7 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/nostr_event.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; @@ -11,11 +12,11 @@ final homeOrderTypeProvider = StateProvider((ref) => OrderType.sell); final filteredOrdersProvider = Provider>((ref) { final allOrdersAsync = ref.watch(orderEventsProvider); final orderType = ref.watch(homeOrderTypeProvider); - final sessionManager = ref.read(sessionManagerProvider); + final sessions = ref.watch(sessionNotifierProvider); return allOrdersAsync.maybeWhen( data: (allOrders) { - final orderIds = sessionManager.sessions.map((s) => s.orderId).toSet(); + final orderIds = sessions.map((s) => s.orderId).toSet(); allOrders .sort((o1, o2) => o1.expirationDate.compareTo(o2.expirationDate)); @@ -23,7 +24,7 @@ final filteredOrdersProvider = Provider>((ref) { final filtered = allOrders.reversed .where((o) => o.orderType == orderType) .where((o) => !orderIds.contains(o.orderId)) - .where((o) => o.status == 'pending') + .where((o) => o.status == Status.pending) .toList(); return filtered; }, diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 9fefb538..1e9fd5ac 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -24,7 +24,7 @@ class HomeScreen extends ConsumerWidget { drawer: const MostroAppDrawer(), body: RefreshIndicator( onRefresh: () async { - return ref.refresh(filteredOrdersProvider); + return await ref.refresh(filteredOrdersProvider); }, child: Container( margin: const EdgeInsets.all(16), @@ -51,7 +51,7 @@ class HomeScreen extends ConsumerWidget { itemBuilder: (context, index) { final order = filteredOrders[index]; return OrderListItem( - order: order); // Your custom widget + order: order); }, ), ), diff --git a/lib/features/home/widgets/order_list_item.dart b/lib/features/home/widgets/order_list_item.dart index 80972e7b..af4df320 100644 --- a/lib/features/home/widgets/order_list_item.dart +++ b/lib/features/home/widgets/order_list_item.dart @@ -1,25 +1,29 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/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/shared/providers/time_provider.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; -class OrderListItem extends StatelessWidget { +class OrderListItem extends ConsumerWidget { final NostrEvent order; const OrderListItem({super.key, required this.order}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + ref.watch(timeProvider); + return GestureDetector( onTap: () { order.orderType == OrderType.buy - ? context.go('/take_buy/${order.orderId}') - : context.go('/take_sell/${order.orderId}'); + ? context.push('/take_buy/${order.orderId}') + : context.push('/take_sell/${order.orderId}'); }, child: CustomCard( color: AppTheme.dark1, diff --git a/lib/features/messages/screens/messages_list_screen.dart b/lib/features/messages/screens/messages_list_screen.dart index ada9f2f1..f9aa976c 100644 --- a/lib/features/messages/screens/messages_list_screen.dart +++ b/lib/features/messages/screens/messages_list_screen.dart @@ -13,7 +13,6 @@ class MessagesListScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Watch the state final chatListState = ref.watch(messagesListNotifierProvider); return Scaffold( diff --git a/lib/features/mostro/mostro_settings_widget.dart b/lib/features/mostro/mostro_settings_widget.dart deleted file mode 100644 index 17055371..00000000 --- a/lib/features/mostro/mostro_settings_widget.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/settings/settings_provider.dart'; - -class MostroSettingsWidget extends ConsumerWidget { - const MostroSettingsWidget({super.key}); - - - @override - Widget build(BuildContext context, WidgetRef ref) { - - final settings = ref.watch(settingsProvider); - - throw UnimplementedError(); - } - -} \ No newline at end of file diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 4d1cae3d..e4bb3e4c 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -125,10 +125,10 @@ class AbstractOrderNotifier extends StateNotifier { break; case Action.disputeInitiatedByYou: case Action.adminSettled: - notifProvider.showInformation(state.action); + notifProvider.showInformation(state.action, values: {}); break; default: - notifProvider.showInformation(state.action); + notifProvider.showInformation(state.action, values: {}); break; } } diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart index 628da48d..1361b252 100644 --- a/lib/features/order/notfiers/add_order_notifier.dart +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -30,7 +30,7 @@ class AddOrderNotifier extends AbstractOrderNotifier { final newNotifier = ref.read(orderNotifierProvider(confirmedOrderId!).notifier); handleOrderUpdate(); - newNotifier.reSubscribe(); + newNotifier.resubscribe(); dispose(); } diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index fca78e39..1b6ea9f6 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -7,7 +7,7 @@ import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.da class OrderNotifier extends AbstractOrderNotifier { OrderNotifier(super.orderRepository, super.orderId, super.ref); - Future reSubscribe() async { + Future resubscribe() async { final stream = orderRepository.resubscribeOrder(orderId); Timer? debounceTimer; stream.listen((order) { @@ -50,4 +50,12 @@ class OrderNotifier extends AbstractOrderNotifier { final stream = await orderRepository.publishOrder(message); await subscribe(stream); } + + Future sendFiatSent() async { + await orderRepository.sendFiatSent(orderId); + } + + Future releaseOrder() async { + await orderRepository.releaseOrder(orderId); + } } diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 1dd2c48e..683d941d 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -46,7 +46,7 @@ class _AddOrderScreenState extends ConsumerState { elevation: 0, leading: IconButton( icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), - onPressed: () => context.go('/'), + onPressed: () => context.pop(), ), title: Text( 'NEW ORDER', diff --git a/lib/features/order/screens/order_screen.dart b/lib/features/order/screens/order_screen.dart deleted file mode 100644 index f09397cf..00000000 --- a/lib/features/order/screens/order_screen.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/enums/order_type.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart' as actions; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; -import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; - -class TakeOrderScreen extends ConsumerStatefulWidget { - final String orderId; - final OrderType orderType; - - const TakeOrderScreen({ - super.key, - required this.orderId, - required this.orderType, - }); - - @override - ConsumerState createState() => _TakeOrderScreenState(); -} - -class _TakeOrderScreenState extends ConsumerState { - bool _isLoading = false; - - @override - void initState() { - super.initState(); - // Listen to notifier state changes: - ref.listen( - orderNotifierProvider(widget.orderId), - (previous, next) { - // If we’re waiting for a response and the state changes from our initial state, - // then navigate accordingly. - if (_isLoading && previous?.action == (widget.orderType == OrderType.buy ? actions.Action.takeBuy : actions.Action.takeSell)) { - setState(() { - _isLoading = false; - }); - // For example, if next.action is now Action.payInvoice, - // navigate to the Lightning Invoice input screen: - if (next.action == actions.Action.payInvoice) { - context.go('/pay_lightning_invoice', extra: widget.orderId); - } - // Or, if you expect a different screen (e.g., for a buyer, to create an invoice), - // adjust the navigation accordingly. - } - }, - ); - } - - @override - Widget build(BuildContext context) { - // If we are in loading state, show a simple loading screen. - if (_isLoading) { - return Scaffold( - appBar: OrderAppBar(title: 'Processing Order'), - backgroundColor: AppTheme.dark1, - body: const Center( - child: CircularProgressIndicator(), - ), - ); - } - - // Otherwise, build the normal UI. (For brevity, this example uses a simple placeholder.) - return Scaffold( - appBar: OrderAppBar( - title: widget.orderType == OrderType.buy ? 'BUY ORDER' : 'SELL ORDER'), - backgroundColor: AppTheme.dark1, - body: Center( - child: ElevatedButton( - onPressed: () async { - final confirmed = await _showConfirmationDialog(); - if (confirmed) { - setState(() { - _isLoading = true; - }); - // Depending on order type, call the appropriate notifier method. - final notifier = ref.read(orderNotifierProvider(widget.orderId).notifier); - // Pass along any required parameters (e.g., fiat amount or LN address) as needed. - // Here we assume null values for simplicity. - if (widget.orderType == OrderType.buy) { - await notifier.takeBuyOrder(widget.orderId, null); - } else { - await notifier.takeSellOrder(widget.orderId, null, null); - } - // The ref.listen above will catch the state update and trigger navigation. - } - }, - child: const Text('CONTINUE'), - ), - ), - ); - } - - Future _showConfirmationDialog() async { - return await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Confirm Order'), - content: const Text('Do you really want to take this order?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('Confirm'), - ), - ], - ); - }, - ) ?? - false; - } -} diff --git a/lib/features/order/widgets/buyer_info.dart b/lib/features/order/widgets/buyer_info.dart deleted file mode 100644 index 6fe8f9b6..00000000 --- a/lib/features/order/widgets/buyer_info.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; -import 'package:flutter/material.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/nostr_event.dart'; - -class BuyerInfo extends StatelessWidget { - final NostrEvent order; - - const BuyerInfo({super.key, required this.order}); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - const CircleAvatar( - backgroundColor: AppTheme.grey2, - foregroundImage: AssetImage('assets/images/launcher-icon.png'), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(order.name!, - style: const TextStyle( - color: AppTheme.cream1, fontWeight: FontWeight.bold)), - Text( - '${order.rating?.totalRating}/${order.rating?.maxRate} (${order.rating?.totalReviews})', - style: const TextStyle(color: AppTheme.mostroGreen), - ), - ], - ), - ), - TextButton( - onPressed: () { - // Implement review logic - }, - child: const Text('Read reviews', - style: TextStyle(color: AppTheme.mostroGreen)), - ), - ], - ); - } -} diff --git a/lib/features/order/widgets/order_app_bar.dart b/lib/features/order/widgets/order_app_bar.dart index 457c00f1..6491ed30 100644 --- a/lib/features/order/widgets/order_app_bar.dart +++ b/lib/features/order/widgets/order_app_bar.dart @@ -16,7 +16,7 @@ class OrderAppBar extends StatelessWidget implements PreferredSizeWidget { elevation: 0, leading: IconButton( icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), - onPressed: () => context.go('/'), + onPressed: () => context.pop(), ), title: Text( title, diff --git a/lib/features/relays/widgets/relay_selector.dart b/lib/features/relays/widgets/relay_selector.dart index 709738c8..83b10c13 100644 --- a/lib/features/relays/widgets/relay_selector.dart +++ b/lib/features/relays/widgets/relay_selector.dart @@ -19,13 +19,12 @@ class RelaySelector extends ConsumerWidget { child: ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), itemCount: settings.relays.length, itemBuilder: (context, index) { final relay = relays[index]; return Card( - color: AppTheme.dark2, - margin: const EdgeInsets.symmetric(vertical: 8), + color: AppTheme.dark1, + margin: EdgeInsets.only(bottom: 12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), diff --git a/lib/features/settings/about_screen.dart b/lib/features/settings/about_screen.dart index 74bae753..45bded11 100644 --- a/lib/features/settings/about_screen.dart +++ b/lib/features/settings/about_screen.dart @@ -42,34 +42,68 @@ class AboutScreen extends ConsumerWidget { color: AppTheme.dark2, borderRadius: BorderRadius.circular(20), ), - child: Column( - children: [ - const SizedBox(height: 24), - Center( - child: CircleAvatar( - radius: 50, - backgroundColor: Colors.grey, - foregroundImage: - AssetImage('assets/images/launcher-icon.png'), + child: SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 24), + Center( + child: CircleAvatar( + radius: 36, + backgroundColor: Colors.grey, + foregroundImage: + AssetImage('assets/images/launcher-icon.png'), + ), ), - ), - const SizedBox(height: 16), - Text( - 'Mostro', - style: textTheme.displayLarge, - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: _buildInstanceDetails( - MostroInstance.fromEvent(nostrEvent)), - ), - ], + const SizedBox(height: 16), + Text( + 'Mostro Mobile Client', + style: textTheme.displayMedium, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: _buildClientDetails(), + ), + Text( + 'Mostro Daemon', + style: textTheme.displayMedium, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: _buildInstanceDetails( + MostroInstance.fromEvent(nostrEvent)), + ), + ], + ), ), ), ), ); } + /// Builds the header displaying details from the client. + Widget _buildClientDetails() { + const String appVersion = + String.fromEnvironment('APP_VERSION', defaultValue: 'N/A'); + const String gitCommit = + String.fromEnvironment('GIT_COMMIT', defaultValue: 'N/A'); + + return CustomCard( + color: AppTheme.dark1, + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Version: $appVersion', + ), + const SizedBox(height: 3), + Text( + 'Commit Hash: $gitCommit', + ), + ], + )); + } + /// Builds the header displaying details from the MostroInstance. Widget _buildInstanceDetails(MostroInstance instance) { final formatter = NumberFormat.decimalPattern(Intl.getCurrentLocale()); diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index bb77ec20..6a35dbfc 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -73,14 +73,7 @@ class SettingsScreen extends ConsumerWidget { // Relays Text('Relays', style: textTheme.titleLarge), const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: AppTheme.dark1, - borderRadius: BorderRadius.circular(24), - ), - child: RelaySelector(), - ), - const SizedBox(height: 12), + RelaySelector(), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ diff --git a/lib/features/trades/providers/trades_provider.dart b/lib/features/trades/providers/trades_provider.dart index 0c93530b..9df86346 100644 --- a/lib/features/trades/providers/trades_provider.dart +++ b/lib/features/trades/providers/trades_provider.dart @@ -1,23 +1,25 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; final filteredTradesProvider = Provider>((ref) { final allOrdersAsync = ref.watch(orderEventsProvider); - final sessionManager = ref.read(sessionManagerProvider); + final sessions = ref.watch(sessionNotifierProvider); return allOrdersAsync.maybeWhen( data: (allOrders) { - final orderIds = sessionManager.sessions.map((s) => s.orderId).toSet(); + final orderIds = sessions.map((s) => s.orderId).toSet(); allOrders .sort((o1, o2) => o1.expirationDate.compareTo(o2.expirationDate)); final filtered = allOrders.reversed .where((o) => orderIds.contains(o.orderId)) - .where((o) => o.status != 'canceled') + .where((o) => o.status != Status.canceled) + .where((o) => o.status != Status.canceledByAdmin) .toList(); return filtered; }, diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart new file mode 100644 index 00000000..fa9e07ae --- /dev/null +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -0,0 +1,227 @@ +import 'package:circular_countdown/circular_countdown.dart'; +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +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/nostr_event.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/utils/currency_utils.dart'; +import 'package:mostro_mobile/shared/widgets/custom_card.dart'; + +class TradeDetailScreen extends ConsumerWidget { + final String orderId; + final TextTheme textTheme = AppTheme.theme.textTheme; + TradeDetailScreen({super.key, required this.orderId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final orderAsyncValue = ref.watch(eventProvider(orderId)); + + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: OrderAppBar(title: 'ORDER DETAILS'), + body: orderAsyncValue.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center(child: Text('Error: $error')), + data: (order) { + if (order == null) { + return Center(child: Text('Order $orderId not found')); + } + // Build the main UI with the order + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + const SizedBox(height: 16), + _buildSellerAmount(ref, order), + const SizedBox(height: 16), + _buildOrderId(context), + const SizedBox(height: 24), + _buildCountDownTime(order.expirationDate), + const SizedBox(height: 36), + // Pass the full order to the action buttons widget. + _buildActionButtons(context, ref, order), + ], + ), + ); + }, + ), + ); + } + + Widget _buildSellerAmount(WidgetRef ref, NostrEvent order) { + final selling = order.orderType == OrderType.sell ? 'selling' : 'buying'; + final amountString = + '${order.fiatAmount} ${order.currency} ${CurrencyUtils.getFlagFromCurrency(order.currency!)}'; + final satAmount = order.amount == '0' ? '' : ' ${order.amount}'; + final price = order.amount != '0' ? '' : 'at market price'; + final premium = int.parse(order.premium ?? '0'); + final premiumText = premium >= 0 + ? premium == 0 + ? '' + : 'with a +$premium% premium' + : 'with a -$premium% discount'; + final method = order.paymentMethods.isNotEmpty + ? order.paymentMethods[0] + : 'No payment method'; + + return CustomCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + spacing: 2, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'You are $selling$satAmount sats for $amountString $price $premiumText', + style: AppTheme.theme.textTheme.bodyLarge, + softWrap: true, + ), + const SizedBox(height: 16), + Text( + 'Created on: ${formatDateTime(order.createdAt!)}', + style: textTheme.bodyLarge, + ), + const SizedBox(height: 16), + Text( + 'The payment method is: $method', + style: textTheme.bodyLarge, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildOrderId(BuildContext context) { + return CustomCard( + padding: const EdgeInsets.all(2), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText( + orderId, + style: TextStyle(color: AppTheme.mostroGreen), + ), + const SizedBox(width: 16), + IconButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: orderId)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Order ID copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + }, + icon: const Icon(Icons.copy), + style: IconButton.styleFrom( + foregroundColor: AppTheme.mostroGreen, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ) + ], + ), + ); + } + + Widget _buildCountDownTime(DateTime expiration) { + Duration countdown = Duration(hours: 0); + final now = DateTime.now(); + if (expiration.isAfter(now)) { + countdown = expiration.difference(now); + } + + return Column( + children: [ + CircularCountdown( + countdownTotal: 24, + countdownRemaining: countdown.inHours, + ), + const SizedBox(height: 16), + Text('Time Left: ${countdown.toString().split('.')[0]}'), + ], + ); + } + + Widget _buildActionButtons( + BuildContext context, WidgetRef ref, NostrEvent order) { + final orderDetailsNotifier = + ref.read(orderNotifierProvider(order.orderId!).notifier); + final message = ref.watch(orderNotifierProvider(order.orderId!)); + + final showCancel = + (order.status == Status.pending || order.status == Status.inProgress); + print(message.serialize()); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton( + onPressed: () { + context.pop(); + }, + style: AppTheme.theme.outlinedButtonTheme.style, + child: const Text('CLOSE'), + ), + const SizedBox(width: 16), + if (showCancel) + ElevatedButton( + onPressed: () async { + await orderDetailsNotifier.cancelOrder(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.red1, + ), + child: const Text('CANCEL'), + ), + const SizedBox(width: 16), + if (message.action == actions.Action.holdInvoicePaymentAccepted) + ElevatedButton( + onPressed: () async { + await orderDetailsNotifier.sendFiatSent(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('FIAT SENT'), + ), + if (message.action == actions.Action.buyerTookOrder) + ElevatedButton( + onPressed: () async { + await orderDetailsNotifier.releaseOrder(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('RELEASE SATS'), + ), + ], + ); + } + + String formatDateTime(DateTime dt) { + final dateFormatter = DateFormat('EEE MMM dd yyyy HH:mm:ss'); + final formattedDate = dateFormatter.format(dt); + final offset = dt.timeZoneOffset; + final sign = offset.isNegative ? '-' : '+'; + final hours = offset.inHours.abs().toString().padLeft(2, '0'); + final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, '0'); + final timeZoneName = dt.timeZoneName; + return '$formattedDate GMT $sign$hours$minutes ($timeZoneName)'; + } +} diff --git a/lib/features/trades/screens/trades_detail_screen.dart b/lib/features/trades/screens/trades_detail_screen.dart deleted file mode 100644 index a99824d6..00000000 --- a/lib/features/trades/screens/trades_detail_screen.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:heroicons/heroicons.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/nostr_event.dart'; -import 'package:mostro_mobile/features/order/widgets/seller_info.dart'; -import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; -import 'package:mostro_mobile/shared/widgets/exchange_rate_widget.dart'; -import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; -import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; -import 'package:mostro_mobile/shared/widgets/custom_card.dart'; - -class TradeDetailScreen extends ConsumerWidget { - final String orderId; - final TextEditingController _fiatAmountController = TextEditingController(); - - TradeDetailScreen({super.key, required this.orderId}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final orderAsyncValue = ref.watch(eventProvider(orderId)); - - return Scaffold( - backgroundColor: AppTheme.dark1, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), - onPressed: () => context.go('/order_book'), - ), - title: Text( - 'TRADE DETAIL', - style: AppTheme.theme.textTheme.displaySmall, - ), - ), - body: orderAsyncValue.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('Error: $error')), - data: (order) { - if (order == null) { - return Center(child: Text('Order $orderId not found')); - } - // Build the main UI with the order - return SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - CustomCard( - padding: const EdgeInsets.all(16), - child: SellerInfo(order: order), - ), - const SizedBox(height: 16), - _buildSellerAmount(ref, order), - const SizedBox(height: 16), - ExchangeRateWidget(currency: order.currency!), - const SizedBox(height: 16), - _buildBuyerAmount(int.tryParse(order.amount!)), - ], - ), - ); - }, - ), - ); - } - - Widget _buildSellerAmount(WidgetRef ref, NostrEvent order) { - final exchangeRateAsyncValue = - ref.watch(exchangeRateProvider(order.currency!)); - return exchangeRateAsyncValue.when( - loading: () => const CircularProgressIndicator(), - error: (error, _) => Text('Exchange rate error: $error'), - data: (exchangeRate) { - // Example usage: exchangeRate might be a double or something - return CustomCard( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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 _buildBuyerAmount(int? amount) { - return Column(children: [ - CustomCard( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CurrencyTextField(controller: _fiatAmountController, label: 'Fiat'), - const SizedBox(height: 8), - ], - ), - ), - const SizedBox(height: 16), - ]); - } - -} diff --git a/lib/features/trades/screens/trades_screen.dart b/lib/features/trades/screens/trades_screen.dart index 58c03a21..8474ab4f 100644 --- a/lib/features/trades/screens/trades_screen.dart +++ b/lib/features/trades/screens/trades_screen.dart @@ -22,7 +22,9 @@ class TradesScreen extends ConsumerWidget { appBar: const MostroAppBar(), drawer: const MostroAppDrawer(), body: RefreshIndicator( - onRefresh: () async {}, + onRefresh: () async { + return await ref.refresh(filteredTradesProvider); + }, child: Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), decoration: BoxDecoration( @@ -34,8 +36,8 @@ class TradesScreen extends ConsumerWidget { Padding( padding: const EdgeInsets.all(16.0), child: Text( - 'My Trades', - style: AppTheme.theme.textTheme.displayLarge, + 'MY TRADES', + style: TextStyle(color: AppTheme.mostroGreen), ), ), _buildFilterButton(context, state), diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index 9942ec9b..407e89da 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -1,24 +1,28 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; -import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.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/nostr_event.dart'; +import 'package:mostro_mobile/shared/providers/time_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; -class TradesListItem extends StatelessWidget { +class TradesListItem extends ConsumerWidget { final NostrEvent trade; const TradesListItem({super.key, required this.trade}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + ref.watch(timeProvider); + return GestureDetector( onTap: () { - context.go('/trade_detail/${trade.orderId}'); + context.push('/trade_detail/${trade.orderId}'); }, child: CustomCard( color: AppTheme.dark1, @@ -41,14 +45,9 @@ class TradesListItem extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + _buildStatusChip(trade.status), Text( - '${toBeginningOfSentenceCase(trade.status)}', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.cream1, - ), - ), - Text( - 'Time: ${trade.expiration}', + '${trade.expiration}', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: AppTheme.cream1, ), @@ -71,7 +70,7 @@ class TradesListItem extends StatelessWidget { } Widget _getOrderOffering(BuildContext context, NostrEvent trade) { - String offering = trade.orderType == OrderType.buy ? 'Selling' : 'Buying'; + String offering = trade.orderType == OrderType.buy ? 'Buying' : 'Selling'; String amountText = (trade.amount != null && trade.amount != '0') ? ' ${trade.amount!}' : ''; @@ -203,4 +202,89 @@ class TradesListItem extends StatelessWidget { : [], ); } + + Widget _buildStatusChip(Status status) { + Color backgroundColor; + Color textColor = AppTheme.cream1; + String label; + + switch (status) { + case Status.active: + backgroundColor = AppTheme.red1; + label = 'Active'; + break; + case Status.canceled: + backgroundColor = AppTheme.grey; + label = 'Canceled'; + break; + case Status.canceledByAdmin: + backgroundColor = AppTheme.red2; + label = 'Canceled by Admin'; + break; + case Status.settledByAdmin: + backgroundColor = AppTheme.yellow; + label = 'Settled by Admin'; + break; + case Status.completedByAdmin: + backgroundColor = AppTheme.grey2; + label = 'Completed by Admin'; + break; + case Status.dispute: + backgroundColor = AppTheme.red1; + label = 'Dispute'; + break; + case Status.expired: + backgroundColor = AppTheme.grey; + label = 'Expired'; + break; + case Status.fiatSent: + backgroundColor = Colors.indigo; + label = 'Fiat Sent'; + break; + case Status.settledHoldInvoice: + backgroundColor = Colors.teal; + label = 'Settled Hold Invoice'; + break; + case Status.pending: + backgroundColor = AppTheme.mostroGreen; + textColor = Colors.black; + label = 'Pending'; + break; + case Status.success: + backgroundColor = Colors.green; + label = 'Success'; + break; + case Status.waitingBuyerInvoice: + backgroundColor = Colors.lightBlue; + label = 'Waiting Buyer Invoice'; + break; + case Status.waitingPayment: + backgroundColor = Colors.lightBlueAccent; + label = 'Waiting Payment'; + break; + case Status.cooperativelyCanceled: + backgroundColor = Colors.deepOrange; + label = 'Cooperatively Canceled'; + break; + case Status.inProgress: + backgroundColor = Colors.blueGrey; + label = 'In Progress'; + break; + } + + return Chip( + backgroundColor: backgroundColor, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4.0), + side: BorderSide.none, + ), + label: Text( + label, + style: TextStyle(color: textColor, fontSize: 12.0), + ), + ); + } } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index b37af4b6..a3f22490 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,28 +1,24 @@ import 'dart:convert'; -import 'package:convert/convert.dart'; -import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:logger/logger.dart'; -import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/amount.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; -import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/payment_request.dart'; import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; class MostroService { final NostrService _nostrService; - final SessionManager _sessionManager; + final SessionNotifier _sessionManager; final _logger = Logger(); - Settings settings; + Settings _settings; - MostroService(this._nostrService, this._sessionManager, this.settings); + MostroService(this._nostrService, this._sessionManager, this._settings); Stream subscribe(Session session) { final filter = NostrFilter(p: [session.tradeKey.public]); @@ -89,107 +85,62 @@ class MostroService { : amount != null ? Amount(amount: amount) : null; - final order = MostroMessage( - action: Action.takeSell, id: orderId, payload: payload); + final order = + MostroMessage(action: Action.takeSell, id: orderId, payload: payload); - final session = await publishOrder(order); + final session = await publishOrder(order); return session; } Future sendInvoice(String orderId, String invoice, int? amount) async { - final content = newMessage(Action.addInvoice, orderId, payload: { - 'payment_request': [ - null, - invoice, - amount, - ] - }); - await sendMessage(orderId, settings.mostroPublicKey, content); + final payload = + PaymentRequest(order: null, lnInvoice: invoice, amount: amount); + final order = + MostroMessage(action: Action.addInvoice, id: orderId, payload: payload); + await publishOrder(order); } Future takeBuyOrder(String orderId, int? amount) async { - final session = await _sessionManager.newSession(orderId: orderId); - final amt = amount != null ? {'amount': amount} : null; - final content = newMessage(Action.takeBuy, orderId, payload: amt); - _logger.i(content); - await sendMessage(orderId, settings.mostroPublicKey, content); + final amt = amount != null ? Amount(amount: amount) : null; + final order = + MostroMessage(action: Action.takeBuy, id: orderId, payload: amt); + final session = await publishOrder(order); return session; } Future publishOrder(MostroMessage order) async { - final session = await _sessionManager.newSession(); + final session = (order.id != null) + ? _sessionManager.getSessionByOrderId(order.id!) ?? + await _sessionManager.newSession(orderId: order.id) + : await _sessionManager.newSession(); + String content; if (!session.fullPrivacy) { order.tradeIndex = session.keyIndex; - final message = {'order': order.toJson()}; - final serializedEvent = jsonEncode(message); - final bytes = utf8.encode(serializedEvent); - final digest = sha256.convert(bytes); - final hash = hex.encode(digest.bytes); - final signature = session.tradeKey.sign(hash); - content = '[$serializedEvent,"$signature"]'; + content = order.serialize(keyPair: session.tradeKey); } else { - content = '[{"order":${jsonEncode(order.toJson())}},null]'; + content = order.serialize(); } _logger.i('Publishing order: $content'); final event = - await createNIP59Event(content, settings.mostroPublicKey, session); + await createNIP59Event(content, _settings.mostroPublicKey, session); await _nostrService.publishEvent(event); return session; } Future cancelOrder(String orderId) async { - final content = newMessage(Action.cancel, orderId); - await sendMessage(orderId, settings.mostroPublicKey, content); + final order = MostroMessage(action: Action.cancel, id: orderId); + await publishOrder(order); } Future sendFiatSent(String orderId) async { - final content = newMessage(Action.fiatSent, orderId); - await sendMessage(orderId, settings.mostroPublicKey, content); + final order = MostroMessage(action: Action.fiatSent, id: orderId); + await publishOrder(order); } Future releaseOrder(String orderId) async { - final content = newMessage(Action.release, orderId); - await sendMessage(orderId, settings.mostroPublicKey, content); - } - - Map newMessage(Action actionType, String orderId, - {Object? payload}) { - return { - 'order': { - 'version': Config.mostroVersion, - 'request_id': null, - 'trade_index': null, - 'id': orderId, - 'action': actionType.value, - 'payload': payload, - }, - }; - } - - Future sendMessage(String orderId, String receiverPubkey, - Map message) async { - try { - final session = _sessionManager.getSessionByOrderId(orderId); - String content; - if (!session!.fullPrivacy) { - message['order']?['trade_index'] = session.keyIndex; - final serializedEvent = jsonEncode(message); - final bytes = utf8.encode(serializedEvent); - final digest = sha256.convert(bytes); - final hash = hex.encode(digest.bytes); - final signature = session.tradeKey.sign(hash); - content = '[$serializedEvent,"$signature"]'; - } else { - content = '[${jsonEncode(message)},null]'; - } - _logger.i(content); - final event = await createNIP59Event(content, receiverPubkey, session); - await _nostrService.publishEvent(event); - } catch (e) { - // catch and throw and log and stuff - _logger.e(e); - } + final order = MostroMessage(action: Action.release, id: orderId); + await publishOrder(order); } Future createNIP59Event( @@ -211,6 +162,6 @@ class MostroService { } void updateSettings(Settings settings) { - this.settings = settings.copyWith(); + _settings = settings.copyWith(); } } diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart new file mode 100644 index 00000000..3a27b67f --- /dev/null +++ b/lib/shared/notifiers/session_notifier.dart @@ -0,0 +1,41 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/repositories/session_manager.dart'; + +class SessionNotifier extends StateNotifier> { + final SessionManager _manager; + + SessionNotifier(this._manager) : super(_manager.sessions); + + Future newSession({String? orderId}) async { + final session = await _manager.newSession(orderId: orderId); + state = _manager.sessions; + return session; + } + + Future saveSession(Session session) async { + await _manager.saveSession(session); + state = _manager.sessions; + } + + Future deleteSession(int sessionId) async { + await _manager.deleteSession(sessionId); + state = _manager.sessions; + } + + Session? getSessionByOrderId(String orderId) { + return _manager.getSessionByOrderId(orderId); + } + + Future loadSession(int keyIndex) async { + final s = await _manager.loadSession(keyIndex); + state = _manager.sessions; + return s; + } + + @override + void dispose() { + _manager.dispose(); + super.dispose(); + } +} diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 411f4742..8d845432 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -4,6 +4,9 @@ import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -21,10 +24,17 @@ final appInitializerProvider = FutureProvider((ref) async { final sessionManager = ref.read(sessionManagerProvider); await sessionManager.init(); + final mostroService = ref.read(mostroServiceProvider); + + ref.listen(settingsProvider, (previous, next) { + sessionManager.updateSettings(next); + mostroService.updateSettings(next); + }); + for (final session in sessionManager.sessions) { if (session.orderId != null) { - //final order = ref.watch(orderNotifierProvider(session.orderId!).notifier); - //order.reSubscribe(); + final order = ref.watch(orderNotifierProvider(session.orderId!).notifier); + order.resubscribe(); } } }); diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index 61f47947..ca0e66ac 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; -import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; @@ -8,23 +7,16 @@ import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; final mostroServiceProvider = Provider((ref) { - final sessionStorage = ref.read(sessionManagerProvider); + final sessionStorage = ref.read(sessionNotifierProvider.notifier); final nostrService = ref.read(nostrServiceProvider); final settings = ref.read(settingsProvider); final mostroService = MostroService(nostrService, sessionStorage, settings); - - ref.listen(settingsProvider, (previous, next) { - mostroService.updateSettings(next); - }); - return mostroService; }); final mostroRepositoryProvider = Provider((ref) { final mostroService = ref.read(mostroServiceProvider); final mostroDatabase = ref.read(mostroStorageProvider); - final mostroRepository = MostroRepository(mostroService, mostroDatabase); - return mostroRepository; }); diff --git a/lib/shared/providers/order_repository_provider.dart b/lib/shared/providers/order_repository_provider.dart index ea391d49..9b2e465f 100644 --- a/lib/shared/providers/order_repository_provider.dart +++ b/lib/shared/providers/order_repository_provider.dart @@ -19,8 +19,6 @@ final orderRepositoryProvider = Provider((ref) { final orderEventsProvider = StreamProvider>((ref) { final orderRepository = ref.read(orderRepositoryProvider); - orderRepository.subscribeToOrders(); - return orderRepository.eventsStream; }); diff --git a/lib/shared/providers/session_manager_provider.dart b/lib/shared/providers/session_manager_provider.dart index b5b4fcf7..c2d39f3d 100644 --- a/lib/shared/providers/session_manager_provider.dart +++ b/lib/shared/providers/session_manager_provider.dart @@ -1,8 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; -import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; import 'package:mostro_mobile/shared/providers/session_storage_provider.dart'; final sessionManagerProvider = Provider((ref) { @@ -10,12 +11,22 @@ final sessionManagerProvider = Provider((ref) { final sessionStorage = ref.read(sessionStorageProvider); final settings = ref.read(settingsProvider); - final sessionManager = - SessionManager(keyManager, sessionStorage, settings.copyWith(), ); - - ref.listen(settingsProvider, (previous, next) { - sessionManager.updateSettings(next); - }); + final sessionManager = SessionManager( + keyManager, + sessionStorage, + settings.copyWith(), + ); return sessionManager; }); + +final sessionsProvider = Provider>((ref) { + final sessionManager = ref.watch(sessionManagerProvider); + return sessionManager.sessions; +}); + +final sessionNotifierProvider = + StateNotifierProvider>((ref) { + final manager = ref.watch(sessionManagerProvider); + return SessionNotifier(manager); +}); diff --git a/lib/shared/providers/time_provider.dart b/lib/shared/providers/time_provider.dart new file mode 100644 index 00000000..f61641c1 --- /dev/null +++ b/lib/shared/providers/time_provider.dart @@ -0,0 +1,10 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Emits a new DateTime every 30 seconds to trigger UI updates +final timeProvider = StreamProvider((ref) { + return Stream.periodic( + const Duration(seconds: 30), + (_) => DateTime.now(), + ); +}); diff --git a/lib/shared/widgets/mostro_app_bar.dart b/lib/shared/widgets/mostro_app_bar.dart index b5214c00..881b1e87 100644 --- a/lib/shared/widgets/mostro_app_bar.dart +++ b/lib/shared/widgets/mostro_app_bar.dart @@ -3,8 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; -import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { const MostroAppBar({super.key}); @@ -27,7 +25,7 @@ class MostroAppBar extends ConsumerWidget implements PreferredSizeWidget { icon: const HeroIcon(HeroIcons.plus, style: HeroIconStyle.outline, color: AppTheme.cream1), onPressed: () { - context.go('/add_order'); + context.push('/add_order'); }, ), IconButton( diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index 83f8e5a8..784f0320 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -32,12 +32,12 @@ import 'package:mostro_mobile/services/mostro_service.dart' as _i4; class _FakeSession_0 extends _i1.SmartFake implements _i2.Session { _FakeSession_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + : super(parent, parentInvocation); } class _FakeNostrEvent_1 extends _i1.SmartFake implements _i3.NostrEvent { _FakeNostrEvent_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + : super(parent, parentInvocation); } /// A class which mocks [MostroService]. @@ -51,10 +51,9 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { @override _i5.Stream<_i6.MostroMessage<_i7.Payload>> subscribe(_i2.Session? session) => (super.noSuchMethod( - Invocation.method(#subscribe, [session]), - returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ) - as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); + Invocation.method(#subscribe, [session]), + returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ) as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); @override _i2.Session? getSessionByOrderId(String? orderId) => @@ -68,74 +67,64 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { String? lnAddress, ) => (super.noSuchMethod( + Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), + returnValue: _i5.Future<_i2.Session>.value( + _FakeSession_0( + this, Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), - returnValue: _i5.Future<_i2.Session>.value( - _FakeSession_0( - this, - Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), - ), - ), - ) - as _i5.Future<_i2.Session>); + ), + ), + ) as _i5.Future<_i2.Session>); @override _i5.Future sendInvoice(String? orderId, String? invoice, int? amount) => (super.noSuchMethod( - Invocation.method(#sendInvoice, [orderId, invoice, amount]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method(#sendInvoice, [orderId, invoice, amount]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override _i5.Future<_i2.Session> takeBuyOrder(String? orderId, int? amount) => (super.noSuchMethod( + Invocation.method(#takeBuyOrder, [orderId, amount]), + returnValue: _i5.Future<_i2.Session>.value( + _FakeSession_0( + this, Invocation.method(#takeBuyOrder, [orderId, amount]), - returnValue: _i5.Future<_i2.Session>.value( - _FakeSession_0( - this, - Invocation.method(#takeBuyOrder, [orderId, amount]), - ), - ), - ) - as _i5.Future<_i2.Session>); + ), + ), + ) as _i5.Future<_i2.Session>); @override _i5.Future<_i2.Session> publishOrder(_i6.MostroMessage<_i7.Payload>? order) => (super.noSuchMethod( - Invocation.method(#publishOrder, [order]), - returnValue: _i5.Future<_i2.Session>.value( - _FakeSession_0(this, Invocation.method(#publishOrder, [order])), - ), - ) - as _i5.Future<_i2.Session>); + Invocation.method(#publishOrder, [order]), + returnValue: _i5.Future<_i2.Session>.value( + _FakeSession_0(this, Invocation.method(#publishOrder, [order])), + ), + ) as _i5.Future<_i2.Session>); @override - _i5.Future cancelOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#cancelOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future cancelOrder(String? orderId) => (super.noSuchMethod( + Invocation.method(#cancelOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i5.Future sendFiatSent(String? orderId) => - (super.noSuchMethod( - Invocation.method(#sendFiatSent, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future sendFiatSent(String? orderId) => (super.noSuchMethod( + Invocation.method(#sendFiatSent, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i5.Future releaseOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#releaseOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future releaseOrder(String? orderId) => (super.noSuchMethod( + Invocation.method(#releaseOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override Map newMessage( @@ -144,14 +133,13 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { Object? payload, }) => (super.noSuchMethod( - Invocation.method( - #newMessage, - [actionType, orderId], - {#payload: payload}, - ), - returnValue: {}, - ) - as Map); + Invocation.method( + #newMessage, + [actionType, orderId], + {#payload: payload}, + ), + returnValue: {}, + ) as Map); @override _i5.Future sendMessage( @@ -160,11 +148,10 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { Map? content, ) => (super.noSuchMethod( - Invocation.method(#sendMessage, [orderId, receiverPubkey, content]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method(#sendMessage, [orderId, receiverPubkey, content]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override _i5.Future<_i3.NostrEvent> createNIP59Event( @@ -173,23 +160,22 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { _i2.Session? session, ) => (super.noSuchMethod( + Invocation.method(#createNIP59Event, [ + content, + recipientPubKey, + session, + ]), + returnValue: _i5.Future<_i3.NostrEvent>.value( + _FakeNostrEvent_1( + this, Invocation.method(#createNIP59Event, [ content, recipientPubKey, session, ]), - returnValue: _i5.Future<_i3.NostrEvent>.value( - _FakeNostrEvent_1( - this, - Invocation.method(#createNIP59Event, [ - content, - recipientPubKey, - session, - ]), - ), - ), - ) - as _i5.Future<_i3.NostrEvent>); + ), + ), + ) as _i5.Future<_i3.NostrEvent>); } /// A class which mocks [MostroRepository]. @@ -201,30 +187,26 @@ class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { } @override - List<_i6.MostroMessage<_i7.Payload>> get allMessages => - (super.noSuchMethod( - Invocation.getter(#allMessages), - returnValue: <_i6.MostroMessage<_i7.Payload>>[], - ) - as List<_i6.MostroMessage<_i7.Payload>>); + List<_i6.MostroMessage<_i7.Payload>> get allMessages => (super.noSuchMethod( + Invocation.getter(#allMessages), + returnValue: <_i6.MostroMessage<_i7.Payload>>[], + ) as List<_i6.MostroMessage<_i7.Payload>>); @override _i5.Future<_i6.MostroMessage<_i7.Payload>?> getOrderById(String? orderId) => (super.noSuchMethod( - Invocation.method(#getOrderById, [orderId]), - returnValue: _i5.Future<_i6.MostroMessage<_i7.Payload>?>.value(), - ) - as _i5.Future<_i6.MostroMessage<_i7.Payload>?>); + Invocation.method(#getOrderById, [orderId]), + returnValue: _i5.Future<_i6.MostroMessage<_i7.Payload>?>.value(), + ) as _i5.Future<_i6.MostroMessage<_i7.Payload>?>); @override _i5.Stream<_i6.MostroMessage<_i7.Payload>> resubscribeOrder( String? orderId, ) => (super.noSuchMethod( - Invocation.method(#resubscribeOrder, [orderId]), - returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ) - as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); + Invocation.method(#resubscribeOrder, [orderId]), + returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ) as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); @override _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> takeSellOrder( @@ -233,13 +215,12 @@ class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { String? lnAddress, ) => (super.noSuchMethod( - Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ), - ) - as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ), + ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); @override _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> takeBuyOrder( @@ -247,123 +228,106 @@ class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { int? amount, ) => (super.noSuchMethod( - Invocation.method(#takeBuyOrder, [orderId, amount]), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ), - ) - as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + Invocation.method(#takeBuyOrder, [orderId, amount]), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ), + ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); @override _i5.Future sendInvoice(String? orderId, String? invoice, int? amount) => (super.noSuchMethod( - Invocation.method(#sendInvoice, [orderId, invoice, amount]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method(#sendInvoice, [orderId, invoice, amount]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> publishOrder( _i6.MostroMessage<_i7.Payload>? order, ) => (super.noSuchMethod( - Invocation.method(#publishOrder, [order]), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ), - ) - as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + Invocation.method(#publishOrder, [order]), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ), + ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); @override - _i5.Future cancelOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#cancelOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future cancelOrder(String? orderId) => (super.noSuchMethod( + Invocation.method(#cancelOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i5.Future saveMessages() => - (super.noSuchMethod( - Invocation.method(#saveMessages, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future saveMessages() => (super.noSuchMethod( + Invocation.method(#saveMessages, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override _i5.Future saveMessage(_i6.MostroMessage<_i7.Payload>? message) => (super.noSuchMethod( - Invocation.method(#saveMessage, [message]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method(#saveMessage, [message]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i5.Future deleteMessage(String? messageId) => - (super.noSuchMethod( - Invocation.method(#deleteMessage, [messageId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future deleteMessage(String? messageId) => (super.noSuchMethod( + Invocation.method(#deleteMessage, [messageId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i5.Future loadMessages() => - (super.noSuchMethod( - Invocation.method(#loadMessages, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future loadMessages() => (super.noSuchMethod( + Invocation.method(#loadMessages, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); @override _i5.Future addOrder(_i6.MostroMessage<_i7.Payload>? order) => (super.noSuchMethod( - Invocation.method(#addOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method(#addOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i5.Future deleteOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#deleteOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future deleteOrder(String? orderId) => (super.noSuchMethod( + Invocation.method(#deleteOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override _i5.Future>> getAllOrders() => (super.noSuchMethod( - Invocation.method(#getAllOrders, []), - returnValue: _i5.Future>>.value( - <_i6.MostroMessage<_i7.Payload>>[], - ), - ) - as _i5.Future>>); + Invocation.method(#getAllOrders, []), + returnValue: _i5.Future>>.value( + <_i6.MostroMessage<_i7.Payload>>[], + ), + ) as _i5.Future>>); @override _i5.Future updateOrder(_i6.MostroMessage<_i7.Payload>? order) => (super.noSuchMethod( - Invocation.method(#updateOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method(#updateOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); } /// A class which mocks [OpenOrdersRepository]. @@ -376,75 +340,62 @@ class MockOpenOrdersRepository extends _i1.Mock } @override - _i5.Stream> get eventsStream => - (super.noSuchMethod( - Invocation.getter(#eventsStream), - returnValue: _i5.Stream>.empty(), - ) - as _i5.Stream>); + _i5.Stream> get eventsStream => (super.noSuchMethod( + Invocation.getter(#eventsStream), + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); @override - List<_i3.NostrEvent> get currentEvents => - (super.noSuchMethod( - Invocation.getter(#currentEvents), - returnValue: <_i3.NostrEvent>[], - ) - as List<_i3.NostrEvent>); + List<_i3.NostrEvent> get currentEvents => (super.noSuchMethod( + Invocation.getter(#currentEvents), + returnValue: <_i3.NostrEvent>[], + ) as List<_i3.NostrEvent>); @override - void subscribeToOrders() => super.noSuchMethod( - Invocation.method(#subscribeToOrders, []), - returnValueForMissingStub: null, - ); + void _subscribeToOrders() => super.noSuchMethod( + Invocation.method(#subscribeToOrders, []), + returnValueForMissingStub: null, + ); @override void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); @override _i5.Future<_i3.NostrEvent?> getOrderById(String? orderId) => (super.noSuchMethod( - Invocation.method(#getOrderById, [orderId]), - returnValue: _i5.Future<_i3.NostrEvent?>.value(), - ) - as _i5.Future<_i3.NostrEvent?>); + Invocation.method(#getOrderById, [orderId]), + returnValue: _i5.Future<_i3.NostrEvent?>.value(), + ) as _i5.Future<_i3.NostrEvent?>); @override - _i5.Future addOrder(_i3.NostrEvent? order) => - (super.noSuchMethod( - Invocation.method(#addOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future addOrder(_i3.NostrEvent? order) => (super.noSuchMethod( + Invocation.method(#addOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i5.Future deleteOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#deleteOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future deleteOrder(String? orderId) => (super.noSuchMethod( + Invocation.method(#deleteOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i5.Future> getAllOrders() => - (super.noSuchMethod( - Invocation.method(#getAllOrders, []), - returnValue: _i5.Future>.value( - <_i3.NostrEvent>[], - ), - ) - as _i5.Future>); + _i5.Future> getAllOrders() => (super.noSuchMethod( + Invocation.method(#getAllOrders, []), + returnValue: _i5.Future>.value( + <_i3.NostrEvent>[], + ), + ) as _i5.Future>); @override - _i5.Future updateOrder(_i3.NostrEvent? order) => - (super.noSuchMethod( - Invocation.method(#updateOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i5.Future updateOrder(_i3.NostrEvent? order) => (super.noSuchMethod( + Invocation.method(#updateOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); } From 54c3480e82de26203bb173ff8efb4d079a069a4e Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Fri, 14 Mar 2025 11:36:57 +1000 Subject: [PATCH 074/149] Fixes error creating new order with ln address #38 Updated release build script Updated dependencies --- .github/workflows/main.yml | 24 ++++++++++++------------ lib/data/models/order.dart | 21 +++++++++++---------- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b1269f1c..0cf9a990 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,16 +35,23 @@ jobs: #4 Install Dependencies - name: Install Dependencies run: flutter pub get - - #5 Building APK + + #5 Extract Version + - name: Extract version from pubspec.yaml + id: extract_version + run: | + version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r') + echo "VERSION=$version" >> $GITHUB_ENV + + #6 Building APK - name: Build APK run: flutter build apk --release --dart-define=APP_VERSION=${{ env.VERSION }} --dart-define=GIT_COMMIT=${{ github.sha }} - #6 Building App Bundle (aab) + #7 Building App Bundle (aab) - name: Build appBundle run: flutter build appbundle --release --dart-define=APP_VERSION=${{ env.VERSION }} --dart-define=GIT_COMMIT=${{ github.sha }} - #7 Build IPA ( IOS Build ) + #8 Build IPA ( IOS Build ) - name: Build IPA run: flutter build ipa --no-codesign --dart-define=APP_VERSION=${{ env.VERSION }} --dart-define=GIT_COMMIT=${{ github.sha }} @@ -53,7 +60,7 @@ jobs: cd build tar -czf ios_build.tar.gz ios - #8 Upload Artifacts + #9 Upload Artifacts - name: Upload Artifacts uses: actions/upload-artifact@v4 with: @@ -63,13 +70,6 @@ jobs: build/app/outputs/bundle/release/app-release.aab build/ios_build.tar.gz - #9 Extract Version - - name: Extract version from pubspec.yaml - id: extract_version - run: | - version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r') - echo "VERSION=$version" >> $GITHUB_ENV - #10 Check if Tag Exists - name: Check if Tag Exists id: check_tag diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart index fa9ec55a..f2f544af 100644 --- a/lib/data/models/order.dart +++ b/lib/data/models/order.dart @@ -60,21 +60,24 @@ class Order implements Payload { 'fiat_amount': fiatAmount, 'payment_method': paymentMethod, 'premium': premium, - 'created_at': createdAt, - 'expires_at': expiresAt, - 'buyer_token': buyerToken, - 'seller_token': sellerToken, } }; if (id != null) data[type]['id'] = id; + + if (buyerInvoice != null) data[type]['buyer_invoice'] = buyerInvoice; + + data[type]['created_at'] = createdAt; + data[type]['expires_at'] = expiresAt; + data[type]['buyer_token'] = buyerToken; + data[type]['seller_token'] = sellerToken; + if (masterBuyerPubkey != null) { - data[type]['master_buyer_pubkey'] = masterBuyerPubkey; + data[type]['buyer_trade_pubkey'] = masterBuyerPubkey; } if (masterSellerPubkey != null) { - data[type]['master_seller_pubkey'] = masterSellerPubkey; + data[type]['seller_trade_pubkey'] = masterSellerPubkey; } - if (buyerInvoice != null) data[type]['buyer_invoice'] = buyerInvoice; return data; } @@ -176,7 +179,5 @@ class Order implements Payload { @override String get type => 'order'; - copyWith({required String buyerInvoice}) { - - } + copyWith({required String buyerInvoice}) {} } diff --git a/pubspec.lock b/pubspec.lock index a5edb11b..4af231b7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -367,10 +367,10 @@ packages: dependency: "direct main" description: name: flutter_hooks - sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 + sha256: b772e710d16d7a20c0740c4f855095026b31c7eb5ba3ab67d2bd52021cd9461d url: "https://pub.dev" source: hosted - version: "0.20.5" + version: "0.21.2" flutter_intl: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 82e40a44..1b0e4033 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,7 +53,7 @@ dependencies: flutter_secure_storage: ^10.0.0-beta.4 go_router: ^14.6.2 bip39: ^1.0.6 - flutter_hooks: ^0.20.5 + flutter_hooks: ^0.21.2 hooks_riverpod: ^2.6.1 flutter_launcher_icons: ^0.14.2 bip32: ^2.0.0 From 95ea019b5a8534fe3f589f7a0ca6e6b0c7ed091c Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Fri, 14 Mar 2025 17:18:37 +1000 Subject: [PATCH 075/149] Addresses When creating a new identity, the orders from the previous identity are not forgotten #36 --- lib/data/models/payload.dart | 2 +- lib/data/repositories/session_manager.dart | 11 ++++++----- lib/data/repositories/session_storage.dart | 4 ++++ lib/features/key_manager/key_management_screen.dart | 3 +++ .../order/notfiers/abstract_order_notifier.dart | 13 ++++++++++++- lib/shared/notifiers/session_notifier.dart | 8 ++++++++ 6 files changed, 34 insertions(+), 7 deletions(-) diff --git a/lib/data/models/payload.dart b/lib/data/models/payload.dart index 418b1aa3..b34e1b88 100644 --- a/lib/data/models/payload.dart +++ b/lib/data/models/payload.dart @@ -15,7 +15,7 @@ abstract class Payload { } else if (json.containsKey('cant_do')) { return CantDo.fromJson(json); } else if (json.containsKey('peer')) { - return Peer.fromJson(json); + return Peer.fromJson(json['peer']); } throw UnsupportedError('Unknown content type'); } diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index b91568d6..b47892f2 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -23,11 +23,7 @@ class SessionManager { /// Returns all in-memory sessions. List get sessions => _sessions.values.toList(); - SessionManager( - this._keyManager, - this._sessionStorage, - this._settings - ) { + SessionManager(this._keyManager, this._sessionStorage, this._settings) { _initializeCleanup(); } @@ -39,6 +35,11 @@ class SessionManager { } } + Future reset() async { + await _sessionStorage.deleteAllSessions(); + _sessions.clear(); + } + void updateSettings(Settings settings) { _settings = settings.copyWith(); } diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index a419c3db..7c56868e 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -61,6 +61,10 @@ class SessionStorage { await _store.record(keyIndex).delete(_database); } + Future deleteAllSessions() async { + await _store.delete(_database); + } + /// Finds and deletes sessions considered expired, returning a list of deleted IDs. Future> deleteExpiredSessions( int sessionExpirationHours, int maxBatchSize) async { diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index b36614e2..9b2214be 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; class KeyManagementScreen extends ConsumerStatefulWidget { const KeyManagementScreen({super.key}); @@ -62,6 +63,8 @@ class _KeyManagementScreenState extends ConsumerState { } Future _generateNewMasterKey() async { + final sessionNotifer = ref.read(sessionNotifierProvider.notifier); + await sessionNotifer.reset(); final keyManager = ref.read(keyManagerProvider); await keyManager.generateAndStoreMasterKey(); await _loadKeys(); diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index e4bb3e4c..e6221e22 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -111,7 +111,7 @@ class AbstractOrderNotifier extends StateNotifier { 'fiat_amount': order?.fiatAmount, 'payment_method': order?.paymentMethod, }); - navProvider.go('/'); + navProvider.go('/'); break; case Action.fiatSentOk: case Action.holdInvoicePaymentSettled: @@ -127,6 +127,17 @@ class AbstractOrderNotifier extends StateNotifier { case Action.adminSettled: notifProvider.showInformation(state.action, values: {}); break; + case Action.paymentFailed: + notifProvider.showInformation(state.action, values: { + 'payment_attempts': -1, + 'payment_retries_interval': -1000 + }); + break; + case Action.released: + notifProvider.showInformation(state.action, values: { + 'seller_npub': null, + }); + default: notifProvider.showInformation(state.action, values: {}); break; diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index 3a27b67f..dc21ab35 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -13,6 +13,14 @@ class SessionNotifier extends StateNotifier> { return session; } + Future reset() async { + await _manager.reset(); + } + + void refresh() { + state = _manager.sessions; + } + Future saveSession(Session session) async { await _manager.saveSession(session); state = _manager.sessions; From 2ede4a591d616fbcefb65189f504d4fecbb62984 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Tue, 18 Mar 2025 10:04:32 +1000 Subject: [PATCH 076/149] Started ChatRoom refactor Updated Action and CantDo enums Updated CantDo and Action string suggestions --- lib/core/app_routes.dart | 4 +- lib/data/models/cant_do.dart | 11 +- .../models/{messages.dart => chat_room.dart} | 6 +- lib/data/models/enums/action.dart | 12 +-- lib/data/models/enums/cant_do_reason.dart | 40 +++++++ lib/data/models/nostr_event.dart | 3 +- lib/data/repositories/chat_repository.dart | 4 + lib/data/repositories/message_repository.dart | 4 - lib/data/repositories/mostro_storage.dart | 3 - .../notifiers/chat_room_notifier.dart | 11 ++ .../notifiers/chat_rooms_notifier.dart | 15 +++ .../notifiers/messages_detail_notifier.dart | 38 ------- .../notifiers/messages_detail_state.dart | 31 ------ .../notifiers/messages_list_notifier.dart | 36 ------- .../notifiers/messages_list_state.dart | 28 ----- .../messages/notifiers/messages_notifier.dart | 13 --- .../providers/chat_room_providers.dart | 19 ++++ .../providers/messages_list_provider.dart | 17 --- ...ages_detail_screen.dart => chat_room.dart} | 50 ++++----- ..._list_screen.dart => chat_rooms_list.dart} | 52 ++++----- .../notfiers/abstract_order_notifier.dart | 30 +++--- .../screens/payment_confirmation_screen.dart | 5 +- .../order/screens/take_order_screen.dart | 2 + .../rate/rate_counterpart_screen.dart | 5 +- .../trades/screens/trade_detail_screen.dart | 4 +- lib/generated/action_localizations.dart | 23 ---- lib/l10n/intl_en.arb | 101 ++++++++++-------- lib/services/mostro_service.dart | 2 +- lib/shared/widgets/bottom_nav_bar.dart | 62 +++++++++-- test/services/mostro_service_test.dart | 8 +- 30 files changed, 277 insertions(+), 362 deletions(-) rename lib/data/models/{messages.dart => chat_room.dart} (57%) create mode 100644 lib/data/models/enums/cant_do_reason.dart create mode 100644 lib/data/repositories/chat_repository.dart delete mode 100644 lib/data/repositories/message_repository.dart create mode 100644 lib/features/messages/notifiers/chat_room_notifier.dart create mode 100644 lib/features/messages/notifiers/chat_rooms_notifier.dart delete mode 100644 lib/features/messages/notifiers/messages_detail_notifier.dart delete mode 100644 lib/features/messages/notifiers/messages_detail_state.dart delete mode 100644 lib/features/messages/notifiers/messages_list_notifier.dart delete mode 100644 lib/features/messages/notifiers/messages_list_state.dart delete mode 100644 lib/features/messages/notifiers/messages_notifier.dart create mode 100644 lib/features/messages/providers/chat_room_providers.dart delete mode 100644 lib/features/messages/providers/messages_list_provider.dart rename lib/features/messages/screens/{messages_detail_screen.dart => chat_room.dart} (70%) rename lib/features/messages/screens/{messages_list_screen.dart => chat_rooms_list.dart} (72%) diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index bd47043b..ab6f323a 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -4,7 +4,7 @@ import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/features/order/screens/add_order_screen.dart'; import 'package:mostro_mobile/features/order/screens/order_confirmation_screen.dart'; import 'package:mostro_mobile/features/auth/screens/welcome_screen.dart'; -import 'package:mostro_mobile/features/messages/screens/messages_list_screen.dart'; +import 'package:mostro_mobile/features/messages/screens/chat_rooms_list.dart'; import 'package:mostro_mobile/features/home/screens/home_screen.dart'; import 'package:mostro_mobile/features/key_manager/key_management_screen.dart'; import 'package:mostro_mobile/features/mostro/mostro_screen.dart'; @@ -52,7 +52,7 @@ final goRouter = GoRouter( ), GoRoute( path: '/chat_list', - builder: (context, state) => const MessagesListScreen(), + builder: (context, state) => const ChatRoomsScreen(), ), GoRoute( path: '/register', diff --git a/lib/data/models/cant_do.dart b/lib/data/models/cant_do.dart index 80053dac..31bc8766 100644 --- a/lib/data/models/cant_do.dart +++ b/lib/data/models/cant_do.dart @@ -1,19 +1,20 @@ +import 'package:mostro_mobile/data/models/enums/cant_do_reason.dart'; import 'package:mostro_mobile/data/models/payload.dart'; class CantDo implements Payload { - final String cantDo; + final CantDoReason cantDoReason; factory CantDo.fromJson(Map json) { - return CantDo(cantDo: json['cant_do']); + return CantDo(cantDoReason: json['cant_do']); } - CantDo({required this.cantDo}); + CantDo({required this.cantDoReason}); @override Map toJson() { return { - type : { - 'cant-do' : cantDo, + type: { + 'cant-do': cantDoReason.toString(), } }; } diff --git a/lib/data/models/messages.dart b/lib/data/models/chat_room.dart similarity index 57% rename from lib/data/models/messages.dart rename to lib/data/models/chat_room.dart index 18f58a2a..fc28033a 100644 --- a/lib/data/models/messages.dart +++ b/lib/data/models/chat_room.dart @@ -1,12 +1,8 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; -class Messages { - final String buyerPubkey; - final String sellerPubkey; +class ChatRoom { final List messages = []; - Messages({required this.buyerPubkey, required this.sellerPubkey}); - void sortMessages() { messages.sort((a, b) => a.createdAt!.compareTo(b.createdAt!)); } diff --git a/lib/data/models/enums/action.dart b/lib/data/models/enums/action.dart index 30f73470..64cf3284 100644 --- a/lib/data/models/enums/action.dart +++ b/lib/data/models/enums/action.dart @@ -35,16 +35,10 @@ enum Action { 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'); + invoiceUpdated('invoice-updated'), + sendDm('send-dm'), + tradePubkey('trade-pubkey'); final String value; diff --git a/lib/data/models/enums/cant_do_reason.dart b/lib/data/models/enums/cant_do_reason.dart new file mode 100644 index 00000000..a9563914 --- /dev/null +++ b/lib/data/models/enums/cant_do_reason.dart @@ -0,0 +1,40 @@ +enum CantDoReason { + invalidSignature('invalid-signature'), + invalidTradeIndex('invalid-trade-index'), + invalidAmount('invalid-amount'), + invalidInvoice('invalid-invoice'), + invalidPaymentRequest('invalid-payment-request'), + invalidPeer('invalid-peer'), + invalidRating('invalid-rating'), + invalidTextMessage('invalid-text-message'), + invalidOrderKind('invalid-order-kind'), + invalidOrderStatus('invalid-order-status'), + invalidPubkey('invalid-pubkey'), + invalidParameters('invalid-parameters'), + orderAlreadyCanceled('order-already-canceled'), + cantCreateUser('cant-create-user'), + isNotYourOrder('is-not-your-order'), + notAllowedByStatus('not-allowed-by-status'), + outOfRangeFiatAmount('out-of-range-fiat-amount'), + outOfRangeSatsAmount('out-of-range-sats-amount'), + isNotYourDispute('is-not-your-dispute'), + disputeCreationError('dispute-creation-error'), + notFound('not-found'), + invalidDisputeStatus('invalid-dispute-status'), + invalidAction('invalid-action'), + pendingOrderExists('pending-order-exists'); + + const CantDoReason(this.value); + final String value; + + static CantDoReason? fromValue(String value) { + return CantDoReason.values + .where((e) => e.value == value) + .fold(null, (_, e) => e); // or firstWhere + catch + } + + @override + String toString() { + return value; + } +} diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index 38ba6bdf..05d74e60 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -46,8 +46,7 @@ extension NostrEventExtensions on NostrEvent { DateTime _getTimeStamp(String timestamp) { final ts = int.parse(timestamp); - return DateTime.fromMillisecondsSinceEpoch(ts * 1000) - .subtract(Duration(hours: 36)); + return DateTime.fromMillisecondsSinceEpoch(ts * 1000).subtract(Duration(hours: 12)); } String _timeAgo(String? ts) { diff --git a/lib/data/repositories/chat_repository.dart b/lib/data/repositories/chat_repository.dart new file mode 100644 index 00000000..198f9364 --- /dev/null +++ b/lib/data/repositories/chat_repository.dart @@ -0,0 +1,4 @@ +class ChatRepository { + + +} \ No newline at end of file diff --git a/lib/data/repositories/message_repository.dart b/lib/data/repositories/message_repository.dart deleted file mode 100644 index 7080471c..00000000 --- a/lib/data/repositories/message_repository.dart +++ /dev/null @@ -1,4 +0,0 @@ -class MessageRepository { - - -} \ No newline at end of file diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 3c74c780..7f9d7deb 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -33,9 +33,6 @@ class MostroStorage implements OrderRepository { if (record == null) return null; try { final msg = MostroMessage.deserialized(jsonEncode(record)); - // If the payload is indeed an Order, you can cast or do a check: - // final order = msg.getPayload(); - // ... return msg; } catch (e) { _logger.e('Error deserializing order $orderId: $e'); diff --git a/lib/features/messages/notifiers/chat_room_notifier.dart b/lib/features/messages/notifiers/chat_room_notifier.dart new file mode 100644 index 00000000..87393960 --- /dev/null +++ b/lib/features/messages/notifiers/chat_room_notifier.dart @@ -0,0 +1,11 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/chat_room.dart'; + +class ChatRoomNotifier extends StateNotifier { + ChatRoomNotifier(super.state); + + Future subscribe(Stream stream) async {} + + void sendMessage(String text) {} +} diff --git a/lib/features/messages/notifiers/chat_rooms_notifier.dart b/lib/features/messages/notifiers/chat_rooms_notifier.dart new file mode 100644 index 00000000..26a06285 --- /dev/null +++ b/lib/features/messages/notifiers/chat_rooms_notifier.dart @@ -0,0 +1,15 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/chat_room.dart'; + +class ChatRoomsNotifier extends StateNotifier> { + ChatRoomsNotifier() : super(const []) { + loadChats(); + } + + Future loadChats() async { + try { + + } catch (e) { + } + } +} diff --git a/lib/features/messages/notifiers/messages_detail_notifier.dart b/lib/features/messages/notifiers/messages_detail_notifier.dart deleted file mode 100644 index e9155033..00000000 --- a/lib/features/messages/notifiers/messages_detail_notifier.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'messages_detail_state.dart'; - -class MessagesDetailNotifier extends StateNotifier { - final String chatId; - - MessagesDetailNotifier(this.chatId) : super(const MessagesDetailState()) { - loadChatDetail(); - } - - Future loadChatDetail() async { - try { - state = state.copyWith(status: MessagesDetailStatus.loading); - - // Simulate a delay / fetch from repo - await Future.delayed(const Duration(seconds: 1)); - // Example data - final chatMessages = []; - - state = state.copyWith( - status: MessagesDetailStatus.loaded, - messages: chatMessages, - error: null, - ); - } catch (e) { - state = state.copyWith( - status: MessagesDetailStatus.error, - error: e.toString(), - ); - } - } - - void sendMessage(String text) { - final updated = List.from(state.messages); - state = state.copyWith(messages: updated); - } -} diff --git a/lib/features/messages/notifiers/messages_detail_state.dart b/lib/features/messages/notifiers/messages_detail_state.dart deleted file mode 100644 index 3e46cf7f..00000000 --- a/lib/features/messages/notifiers/messages_detail_state.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; - -enum MessagesDetailStatus { - loading, - loaded, - error, -} - -class MessagesDetailState { - final MessagesDetailStatus status; - final List messages; - final String? error; - - const MessagesDetailState({ - this.status = MessagesDetailStatus.loading, - this.messages = const [], - this.error, - }); - - MessagesDetailState copyWith({ - MessagesDetailStatus? status, - List? messages, - String? error, - }) { - return MessagesDetailState( - status: status ?? this.status, - messages: messages ?? this.messages, - error: error, - ); - } -} diff --git a/lib/features/messages/notifiers/messages_list_notifier.dart b/lib/features/messages/notifiers/messages_list_notifier.dart deleted file mode 100644 index 534566b2..00000000 --- a/lib/features/messages/notifiers/messages_list_notifier.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:dart_nostr/dart_nostr.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'messages_list_state.dart'; - -class MessagesListNotifier extends StateNotifier { - MessagesListNotifier() : super(const MessagesListState()) { - loadChats(); - } - - Future loadChats() async { - try { - // 1) Start loading - state = state.copyWith(status: MessagesListStatus.loading); - - // 2) Simulate or fetch real chat data from a repository - // For example: - // final chats = await chatRepository.getAllChats(); - // For now, a mock: - await Future.delayed(const Duration(seconds: 1)); // simulating network - final chats = []; - - // 3) Loaded - state = state.copyWith( - status: MessagesListStatus.loaded, - chats: chats, - errorMessage: null, - ); - } catch (e) { - // On error - state = state.copyWith( - status: MessagesListStatus.error, - errorMessage: e.toString(), - ); - } - } -} diff --git a/lib/features/messages/notifiers/messages_list_state.dart b/lib/features/messages/notifiers/messages_list_state.dart deleted file mode 100644 index f8ec8a42..00000000 --- a/lib/features/messages/notifiers/messages_list_state.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; - -enum MessagesListStatus { loading, loaded, error, empty } - -class MessagesListState { - final MessagesListStatus status; - final List chats; - final String? errorMessage; - - const MessagesListState({ - this.status = MessagesListStatus.loading, - this.chats = const [], - this.errorMessage, - }); - - // A copyWith for convenience - MessagesListState copyWith({ - MessagesListStatus? status, - List? chats, - String? errorMessage, - }) { - return MessagesListState( - status: status ?? this.status, - chats: chats ?? this.chats, - errorMessage: errorMessage ?? this.errorMessage, - ); - } -} diff --git a/lib/features/messages/notifiers/messages_notifier.dart b/lib/features/messages/notifiers/messages_notifier.dart deleted file mode 100644 index 137a08fe..00000000 --- a/lib/features/messages/notifiers/messages_notifier.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:dart_nostr/dart_nostr.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/messages.dart'; - -class MessagesNotifier extends StateNotifier { - MessagesNotifier(super.state); - - - Future subscribe(Stream stream) async { - } - - -} \ No newline at end of file diff --git a/lib/features/messages/providers/chat_room_providers.dart b/lib/features/messages/providers/chat_room_providers.dart new file mode 100644 index 00000000..5a53e8d2 --- /dev/null +++ b/lib/features/messages/providers/chat_room_providers.dart @@ -0,0 +1,19 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/chat_room.dart'; +import 'package:mostro_mobile/features/messages/notifiers/chat_room_notifier.dart'; +import 'package:mostro_mobile/features/messages/notifiers/chat_rooms_notifier.dart'; + +final messagesListNotifierProvider = + StateNotifierProvider>( + (ref) => ChatRoomsNotifier(), +); + +final messagesDetailNotifierProvider = StateNotifierProvider.family< + ChatRoomNotifier, ChatRoom, String>((ref, chatId) { + return ChatRoomNotifier(ChatRoom()); +}); + +final chatRoomsProvider = + StateNotifierProvider>((ref) { + return ChatRoomsNotifier(); +}); diff --git a/lib/features/messages/providers/messages_list_provider.dart b/lib/features/messages/providers/messages_list_provider.dart deleted file mode 100644 index 4a3c887f..00000000 --- a/lib/features/messages/providers/messages_list_provider.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/messages/notifiers/messages_detail_notifier.dart'; -import 'package:mostro_mobile/features/messages/notifiers/messages_detail_state.dart'; -import 'package:mostro_mobile/features/messages/notifiers/messages_list_notifier.dart'; -import 'package:mostro_mobile/features/messages/notifiers/messages_list_state.dart'; - -final messagesListNotifierProvider = - StateNotifierProvider( - (ref) => MessagesListNotifier(), -); - -final messagesDetailNotifierProvider = StateNotifierProvider.family< - MessagesDetailNotifier, MessagesDetailState, String>((ref, chatId) { - return MessagesDetailNotifier(chatId); -}); - - diff --git a/lib/features/messages/screens/messages_detail_screen.dart b/lib/features/messages/screens/chat_room.dart similarity index 70% rename from lib/features/messages/screens/messages_detail_screen.dart rename to lib/features/messages/screens/chat_room.dart index 3f22c40f..d054cea5 100644 --- a/lib/features/messages/screens/messages_detail_screen.dart +++ b/lib/features/messages/screens/chat_room.dart @@ -3,21 +3,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/features/messages/notifiers/messages_detail_state.dart'; -import 'package:mostro_mobile/features/messages/providers/messages_list_provider.dart'; +import 'package:mostro_mobile/data/models/chat_room.dart'; +import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; -class MessagesDetailScreen extends ConsumerStatefulWidget { +class ChatRoomScreen extends ConsumerStatefulWidget { final String chatId; - const MessagesDetailScreen({super.key, required this.chatId}); + const ChatRoomScreen({super.key, required this.chatId}); @override - ConsumerState createState() => - _MessagesDetailScreenState(); + ConsumerState createState() => _MessagesDetailScreenState(); } -class _MessagesDetailScreenState extends ConsumerState { +class _MessagesDetailScreenState extends ConsumerState { final TextEditingController _textController = TextEditingController(); @override @@ -41,28 +40,21 @@ class _MessagesDetailScreenState extends ConsumerState { ); } - Widget _buildBody(MessagesDetailState state) { - switch (state.status) { - case MessagesDetailStatus.loading: - return const Center(child: CircularProgressIndicator()); - case MessagesDetailStatus.loaded: - return Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: state.messages.length, - itemBuilder: (context, index) { - final message = state.messages[index]; - return _buildMessageBubble(message); - }, - ), - ), - _buildMessageInput(), - ], - ); - case MessagesDetailStatus.error: - return Center(child: Text(state.error ?? 'An error occurred')); - } + Widget _buildBody(ChatRoom state) { + return Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: state.messages.length, + itemBuilder: (context, index) { + final message = state.messages[index]; + return _buildMessageBubble(message); + }, + ), + ), + _buildMessageInput(), + ], + ); } Widget _buildMessageBubble(NostrEvent message) { diff --git a/lib/features/messages/screens/messages_list_screen.dart b/lib/features/messages/screens/chat_rooms_list.dart similarity index 72% rename from lib/features/messages/screens/messages_list_screen.dart rename to lib/features/messages/screens/chat_rooms_list.dart index f9aa976c..d3270713 100644 --- a/lib/features/messages/screens/messages_list_screen.dart +++ b/lib/features/messages/screens/chat_rooms_list.dart @@ -2,14 +2,14 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/features/messages/notifiers/messages_list_state.dart'; -import 'package:mostro_mobile/features/messages/providers/messages_list_provider.dart'; +import 'package:mostro_mobile/data/models/chat_room.dart'; +import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; -class MessagesListScreen extends ConsumerWidget { - const MessagesListScreen({super.key}); +class ChatRoomsScreen extends ConsumerWidget { + const ChatRoomsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -30,8 +30,8 @@ class MessagesListScreen extends ConsumerWidget { Padding( padding: const EdgeInsets.all(16.0), child: Text( - 'Messages', - style: AppTheme.theme.textTheme.displayLarge, + 'MESSAGES', + style: TextStyle(color: AppTheme.mostroGreen), ), ), Expanded( @@ -44,36 +44,20 @@ class MessagesListScreen extends ConsumerWidget { ); } - Widget _buildBody(MessagesListState state) { - switch (state.status) { - case MessagesListStatus.loading: - return const Center(child: CircularProgressIndicator()); - case MessagesListStatus.loaded: - if (state.chats.isEmpty) { - return Center( - child: Text( - 'No messages available', - style: AppTheme.theme.textTheme.displaySmall, - )); - } - return ListView.builder( - itemCount: state.chats.length, - itemBuilder: (context, index) { - return ChatListItem(chat: state.chats[index]); - }, - ); - case MessagesListStatus.error: - return Center( + Widget _buildBody(List state) { + if (state.isEmpty) { + return Center( child: Text( - state.errorMessage ?? 'An error occurred', - style: const TextStyle(color: Colors.red), - ), - ); - case MessagesListStatus.empty: - return const Center( - child: Text('No chats available', - style: TextStyle(color: AppTheme.cream1))); + 'No messages available', + style: AppTheme.theme.textTheme.displaySmall, + )); } + return ListView.builder( + itemCount: state.length, + itemBuilder: (context, index) { + return ChatListItem(chat: state[index].messages.first); + }, + ); } } diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index e6221e22..0e0d3c7c 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -4,6 +4,7 @@ import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/enums/cant_do_reason.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; @@ -38,6 +39,17 @@ class AbstractOrderNotifier extends StateNotifier { void handleError(Object err) { logger.e(err); + if (state.payload is CantDo) { + final cantdo = state.getPayload()!; + + switch (cantdo.cantDoReason) { + case CantDoReason.outOfRangeSatsAmount: + break; + case CantDoReason.outOfRangeFiatAmount: + break; + default: + } + } } void handleOrderUpdate() { @@ -51,8 +63,8 @@ class AbstractOrderNotifier extends StateNotifier { break; case Action.cantDo: final cantDo = state.getPayload(); - notifProvider - .showInformation(state.action, values: {'action': cantDo?.cantDo}); + notifProvider.showInformation(state.action, + values: {'action': cantDo?.cantDoReason.toString()}); break; case Action.newOrder: navProvider.go('/order_confirmed/${state.id!}'); @@ -60,20 +72,6 @@ class AbstractOrderNotifier extends StateNotifier { case Action.payInvoice: navProvider.go('/pay_invoice/${state.id!}'); break; - case Action.outOfRangeSatsAmount: - final order = state.getPayload(); - notifProvider.showInformation(state.action, values: { - 'min_order_amount': order?.minAmount, - 'max_order_amount': order?.maxAmount - }); - break; - case Action.outOfRangeFiatAmount: - final order = state.getPayload(); - notifProvider.showInformation(state.action, values: { - 'min_amount': order?.minAmount, - 'max_amount': order?.maxAmount - }); - break; case Action.waitingSellerToPay: navProvider.go('/'); notifProvider.showInformation(state.action, values: { diff --git a/lib/features/order/screens/payment_confirmation_screen.dart b/lib/features/order/screens/payment_confirmation_screen.dart index 39d9496b..1ae9caa3 100644 --- a/lib/features/order/screens/payment_confirmation_screen.dart +++ b/lib/features/order/screens/payment_confirmation_screen.dart @@ -39,9 +39,6 @@ class PaymentConfirmationScreen extends ConsumerWidget { Widget _buildBody(BuildContext context, WidgetRef ref, MostroMessage state) { switch (state.action) { - case action.Action.notFound: - return const Center(child: CircularProgressIndicator()); - case action.Action.purchaseCompleted: final satoshis = 0; @@ -108,7 +105,7 @@ class PaymentConfirmationScreen extends ConsumerWidget { ); case action.Action.cantDo: - final error = state.getPayload()?.cantDo; + final error = state.getPayload()?.cantDoReason; return Center( child: Text( 'Error: $error', diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 223c058c..4f91cec9 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -146,6 +146,8 @@ class TakeOrderScreen extends ConsumerWidget { if (expiration.isAfter(now)) { countdown = expiration.difference(now); } + print(countdown); + return Column( children: [ diff --git a/lib/features/rate/rate_counterpart_screen.dart b/lib/features/rate/rate_counterpart_screen.dart index 8a400a0b..2218f5ec 100644 --- a/lib/features/rate/rate_counterpart_screen.dart +++ b/lib/features/rate/rate_counterpart_screen.dart @@ -16,14 +16,11 @@ class _RateCounterpartScreenState extends State { final _logger = Logger(); void _submitRating() { - // Here you would typically call a notifier/provider method to persist the rating. - // For now, we'll simply print it and navigate back. _logger.i('Rating submitted: $_rating'); - // Optionally, show a confirmation SnackBar: + ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Rating submitted!')), ); - // Navigate back (or to a confirmation screen) using GoRouter. context.pop(); } diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index fa9e07ae..b1f606e0 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -143,8 +143,9 @@ class TradeDetailScreen extends ConsumerWidget { Duration countdown = Duration(hours: 0); final now = DateTime.now(); if (expiration.isAfter(now)) { - countdown = expiration.difference(now); + countdown = now.difference(expiration); } + print(countdown); return Column( children: [ @@ -166,7 +167,6 @@ class TradeDetailScreen extends ConsumerWidget { final showCancel = (order.status == Status.pending || order.status == Status.inProgress); - print(message.serialize()); return Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/generated/action_localizations.dart b/lib/generated/action_localizations.dart index 2aa2a2ce..9d106400 100644 --- a/lib/generated/action_localizations.dart +++ b/lib/generated/action_localizations.dart @@ -87,29 +87,6 @@ extension ActionLocalizationX on S { } else { return adminTookDisputeUsers(placeholders['admin_npub']); } - case Action.isNotYourOrder: - return isNotYourOrder(placeholders['action']); - case Action.notAllowedByStatus: - return notAllowedByStatus(placeholders['action'], placeholders['id'], - placeholders['order_status']); - case Action.outOfRangeFiatAmount: - return outOfRangeFiatAmount( - placeholders['min_amount'], placeholders['max_amount']); - case Action.isNotYourDispute: - return isNotYourDispute; - case Action.notFound: - return notFound; - case Action.incorrectInvoiceAmount: - if (placeholders['amount']) { - return incorrectInvoiceAmountBuyerAddInvoice(placeholders['amount']); - } else { - return incorrectInvoiceAmountBuyerNewOrder; - } - case Action.invalidSatsAmount: - return invalidSatsAmount; - case Action.outOfRangeSatsAmount: - return outOfRangeSatsAmount( - placeholders['min_order_amount'], placeholders['max_order_amount']); case Action.paymentFailed: return paymentFailed(placeholders['payment_attempts'], placeholders['payment_retries_interval']); diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index e1ff2efc..5424d447 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1,44 +1,59 @@ { - "@@locale": "en", - "newOrder": "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}!", - "payInvoice": "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.", - "addInvoice": "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.", - "waitingSellerToPay": "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.", - "waitingBuyerInvoice": "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.", - "buyerInvoiceAccepted": "Invoice has been successfully saved!", - "holdInvoicePaymentAccepted": "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.", - "buyerTookOrder": "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.", - "fiatSentOkBuyer": "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.", - "fiatSentOkSeller": "{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.", - "purchaseCompleted": "Your satoshis purchase has been completed successfully. I have paid your invoice, enjoy sound money!", - "holdInvoicePaymentSettled": "Your Sats sale has been completed after confirming the payment from {buyer_npub}.", - "rate": "Please rate your counterparty", - "rateReceived": "Rating successfully saved!", - "cooperativeCancelInitiatedByYou": "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.", - "cooperativeCancelInitiatedByPeer": "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.", - "cooperativeCancelAccepted": "Order {id} has been successfully cancelled!", - "disputeInitiatedByYou": "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}.", - "disputeInitiatedByPeer": "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}.", - "adminTookDisputeAdmin": "Here are the details of the dispute order you have taken: {details}. You need to determine which user is correct and decide whether to cancel or complete the order. Please note that your decision will be final and cannot be reversed.", - "adminTookDisputeUsers": "The solver {admin_npub} will handle your dispute. You can contact them directly, but if they reach out to you first, make sure to ask them for your dispute token.", - "adminCanceledAdmin": "You have cancelled the order ID: {id}!", - "adminCanceledUsers": "Admin has cancelled the order ID: {id}!", - "adminSettledAdmin": "You have completed the order ID: {id}!", - "adminSettledUsers": "Admin has completed the order ID: {id}!", - "isNotYourDispute": "This dispute was not assigned to you!", - "notFound": "Dispute not found.", - "paymentFailed": "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.", - "invoiceUpdated": "Invoice has been successfully updated!", - "holdInvoicePaymentCanceled": "The invoice was cancelled; your Sats will be available in your wallet again.", - "cantDo": "You are not allowed to {action} for this order!", - "adminAddSolver": "You have successfully added the solver {npub}.", - "isNotYourOrder": "You did not create this order and are not authorized to {action} it.", - "notAllowedByStatus": "You are not allowed to {action} because order Id {id} status is {order_status}.", - "outOfRangeFiatAmount": "The requested amount is incorrect and may be outside the acceptable range. The minimum is {min_amount} and the maximum is {max_amount}.", - "incorrectInvoiceAmountBuyerNewOrder": "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.", - "incorrectInvoiceAmountBuyerAddInvoice": "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.", - "invalidSatsAmount": "The specified Sats amount is invalid.", - "outOfRangeSatsAmount": "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." - } \ No newline at end of file + "@@locale": "en", + "newOrder": "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 canceled the order ID: {id}.", + "payInvoice": "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 canceled.", + "addInvoice": "Please send me an invoice for {amount} satoshis equivalent to {fiat_code} {fiat_amount}. This is where I will send the funds upon trade completion. If you don't provide the invoice within {expiration_seconds}, the trade will be canceled.", + "waitingSellerToPay": "Please wait. I’ve sent a payment request to the seller to send the Sats for the order ID {id}. If the seller doesn’t complete the payment within {expiration_seconds}, the trade will be canceled.", + "waitingBuyerInvoice": "Payment received! Your Sats are now 'held' in your wallet. I’ve requested the buyer to provide an invoice. If they don’t do so within {expiration_seconds}, your Sats will return to your wallet, and the trade will be canceled.", + "buyerInvoiceAccepted": "The invoice has been successfully saved.", + "holdInvoicePaymentAccepted": "Contact the seller at {seller_npub} to arrange how to send {fiat_code} {fiat_amount} using {payment_method}. Once you send the fiat money, please notify me with fiat-sent.", + "buyerTookOrder": "Contact the buyer at {buyer_npub} to inform them how to send {fiat_code} {fiat_amount} through {payment_method}. You’ll be notified when the buyer confirms the fiat payment. Afterward, 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.", + "fiatSentOkBuyer": "I have informed {seller_npub} that you sent the fiat money. If the seller confirms receipt, they will release the funds. If they refuse, you can open a dispute.", + "fiatSentOkSeller": "{buyer_npub} has informed you that they sent 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 released the Sats! Expect your invoice to be paid shortly. Ensure your wallet is online to receive via Lightning Network.", + "purchaseCompleted": "Your purchase of Bitcoin has been completed successfully. I have paid your invoice; enjoy sound money!", + "holdInvoicePaymentSettled": "Your Sats sale has been completed after confirming the payment from {buyer_npub}.", + "rate": "Please rate your counterparty", + "rateReceived": "Rating successfully saved!", + "cooperativeCancelInitiatedByYou": "You have initiated the cancellation of the order ID: {id}. Your counterparty must agree. If they do not respond, you can open a dispute. Note that no administrator will contact you regarding this cancellation unless you open a dispute first.", + "cooperativeCancelInitiatedByPeer": "Your counterparty wants to cancel order ID: {id}. If you agree, please send me cancel-order-message. Note that no administrator will contact you regarding this cancellation unless you open a dispute first.", + "cooperativeCancelAccepted": "Order {id} has been successfully canceled!", + "disputeInitiatedByYou": "You have initiated a dispute for order Id: {id}. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. Your dispute token is: {user_token}.", + "disputeInitiatedByPeer": "Your counterparty has initiated a dispute for order Id: {id}. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. Your dispute token is: {user_token}.", + "adminTookDisputeAdmin": "Here are the details of the dispute order you have taken: {details}. You need to determine which user is correct and decide whether to cancel or complete the order. Please note that your decision will be final and cannot be reversed.", + "adminTookDisputeUsers": "The solver {admin_npub} will handle your dispute. You can contact them directly, but if they reach out to you first, make sure to ask them for your dispute token.", + "adminCanceledAdmin": "You have canceled the order ID: {id}.", + "adminCanceledUsers": "Admin has canceled the order ID: {id}.", + "adminSettledAdmin": "You have completed the order ID: {id}.", + "adminSettledUsers": "Admin has completed the order ID: {id}.", + "paymentFailed": "I couldn’t send the Sats. I will try {payment_attempts} more times in {payment_retries_interval} minutes. Please ensure your node or wallet is online.", + "invoiceUpdated": "The invoice has been successfully updated!", + "holdInvoicePaymentCanceled": "The invoice was canceled; your Sats are available in your wallet again.", + "cantDo": "You are not allowed to {action} for this order!", + "adminAddSolver": "You have successfully added the solver {npub}.", + "invalidSignature": "The action cannot be completed because the signature is invalid.", + "invalidTradeIndex": "The provided trade index is invalid. Please ensure your client is synchronized and try again.", + "invalidAmount": "The provided amount is invalid. Please verify it and try again.", + "invalidInvoice": "The provided Lightning invoice is invalid. Please check the invoice details and try again.", + "invalidPaymentRequest": "The payment request is invalid or cannot be processed.", + "invalidPeer": "You are not authorized to perform this action.", + "invalidRating": "The rating value is invalid or out of range.", + "invalidTextMessage": "The text message is invalid or contains prohibited content.", + "invalidOrderKind": "The order kind is invalid.", + "invalidOrderStatus": "The action cannot be completed due to the current order status.", + "invalidPubkey": "The action cannot be completed because the public key is invalid.", + "invalidParameters": "The action cannot be completed due to invalid parameters. Please review the provided values and try again.", + "orderAlreadyCanceled": "The action cannot be completed because the order has already been canceled.", + "cantCreateUser": "The action cannot be completed because the user could not be created.", + "isNotYourOrder": "This order does not belong to you.", + "notAllowedByStatus": "The action cannot be completed because order Id {id} status is {order_status}.", + "outOfRangeFiatAmount": "The requested fiat amount is outside the acceptable range ({min_amount}–{max_amount}).", + "outOfRangeSatsAmount": "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.", + "isNotYourDispute": "This dispute is not assigned to you.", + "disputeCreationError": "A dispute cannot be created for this order.", + "notFound": "The requested dispute could not be found.", + "invalidDisputeStatus": "The dispute status is invalid.", + "invalidAction": "The requested action is invalid.", + "pendingOrderExists": "A pending order already exists." +} diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index a3f22490..ebc5283a 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -66,7 +66,7 @@ class MostroService { if (msgMap.containsKey('cant-do')) { final msg = MostroMessage.fromJson(msgMap['cant-do']); final cantdo = msg.getPayload(); - _logger.e('Can\'t Do: ${cantdo?.cantDo}'); + _logger.e('Can\'t Do: ${cantdo?.cantDoReason}'); return msg; } throw FormatException('Result not found ${decryptedEvent.content}'); diff --git a/lib/shared/widgets/bottom_nav_bar.dart b/lib/shared/widgets/bottom_nav_bar.dart index 9b698338..50d4cedb 100644 --- a/lib/shared/widgets/bottom_nav_bar.dart +++ b/lib/shared/widgets/bottom_nav_bar.dart @@ -1,13 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -class BottomNavBar extends StatelessWidget { +final chatCountProvider = StateProvider((ref) => 0); +final orderBookNotificationCountProvider = StateProvider((ref) => 0); + +class BottomNavBar extends ConsumerWidget { const BottomNavBar({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + // Watch the notification counts. + final int chatCount = ref.watch(chatCountProvider); + final int orderNotificationCount = ref.watch(orderBookNotificationCountProvider); + return Container( padding: const EdgeInsets.symmetric(vertical: 16), child: Center( @@ -22,8 +30,20 @@ class BottomNavBar extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildNavItem(context, HeroIcons.bookOpen, 0), - _buildNavItem(context, HeroIcons.bookmarkSquare, 1), - _buildNavItem(context, HeroIcons.chatBubbleLeftRight, 2), + // For order book notifications (index 1) + _buildNavItem( + context, + HeroIcons.bookmarkSquare, + 1, + notificationCount: orderNotificationCount, + ), + // For chat messages (index 2) + _buildNavItem( + context, + HeroIcons.chatBubbleLeftRight, + 2, + notificationCount: chatCount, + ), _buildNavItem(context, HeroIcons.bolt, 3), ], ), @@ -32,7 +52,8 @@ class BottomNavBar extends StatelessWidget { ); } - Widget _buildNavItem(BuildContext context, HeroIcons icon, int index) { + Widget _buildNavItem(BuildContext context, HeroIcons icon, int index, + {int? notificationCount}) { bool isActive = _isActive(context, index); return GestureDetector( onTap: () => _onItemTapped(context, index), @@ -42,11 +63,30 @@ class BottomNavBar extends StatelessWidget { color: isActive ? const Color(0xFF8CC541) : Colors.transparent, borderRadius: BorderRadius.circular(28), ), - child: HeroIcon( - icon, - style: HeroIconStyle.outline, - color: Colors.black, - size: 24, + child: Stack( + clipBehavior: Clip.none, + children: [ + HeroIcon( + icon, + style: HeroIconStyle.outline, + color: Colors.black, + size: 24, + ), + if (notificationCount != null && notificationCount > 0) + // Position the red dot at the top left corner of the icon. + Positioned( + top: -2, + left: -2, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], ), ), ); @@ -89,7 +129,7 @@ class BottomNavBar extends StatelessWidget { final currentLocation = GoRouterState.of(context).uri.toString(); if (currentLocation != nextRoute) { - context.go(nextRoute); + context.push(nextRoute); } } } diff --git a/test/services/mostro_service_test.dart b/test/services/mostro_service_test.dart index 0aad874b..8b6f95e8 100644 --- a/test/services/mostro_service_test.dart +++ b/test/services/mostro_service_test.dart @@ -8,26 +8,30 @@ import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; import 'mostro_service_test.mocks.dart'; import 'mostro_service_helper_functions.dart'; -@GenerateMocks([NostrService, SessionManager]) +@GenerateMocks([NostrService, SessionManager, SessionNotifier]) void main() { late MostroService mostroService; late KeyDerivator keyDerivator; late MockNostrService mockNostrService; late MockSessionManager mockSessionManager; + late MockSessionNotifier mockSessionNotifier; final mockServerTradeIndex = MockServerTradeIndex(); setUp(() { mockNostrService = MockNostrService(); mockSessionManager = MockSessionManager(); - mostroService = MostroService(mockNostrService, mockSessionManager); + mostroService = MostroService(mockNostrService, mockSessionNotifier, + Settings(relays: [], fullPrivacyMode: true, mostroPublicKey: 'xxx')); keyDerivator = KeyDerivator("m/44'/1237'/38383'/0"); }); From 983f3b0953366ec3d046981231f92072ba05d8e1 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 19 Mar 2025 16:43:07 +1000 Subject: [PATCH 077/149] Updated i10n strings to latest suggestions Included separate CantDo strings in i10n Added MostroMessageDetail widget to TradeDetailScreen --- devtools_options.yaml | 1 + flow.mermaid | 113 +++ l10n.yaml | 3 + lib/core/app.dart | 2 +- lib/data/models/cant_do.dart | 2 +- lib/data/models/dispute.dart | 14 +- lib/data/models/enums/cant_do_reason.dart | 66 +- lib/data/models/enums/status.dart | 6 +- lib/data/models/payload.dart | 5 +- lib/data/models/rating.dart | 2 +- lib/data/repositories/mostro_repository.dart | 4 + .../notfiers/abstract_order_notifier.dart | 2 +- .../order/notfiers/order_notifier.dart | 5 + .../screens/order_confirmation_screen.dart | 43 +- .../order/screens/take_order_screen.dart | 2 - .../trades/screens/trade_detail_screen.dart | 184 +++- .../widgets/mostro_message_detail_widget.dart | 274 ++++++ lib/generated/action_localizations.dart | 10 +- lib/generated/l10n.dart | 928 +++++++++--------- lib/generated/l10n_en.dart | 236 +++++ lib/services/mostro_service.dart | 51 +- .../widgets/notification_listener_widget.dart | 4 +- test/mocks.mocks.dart | 452 +++++---- test/services/mostro_service_test.dart | 1 + test/services/mostro_service_test.mocks.dart | 376 +++++-- 25 files changed, 1865 insertions(+), 921 deletions(-) create mode 100644 flow.mermaid create mode 100644 lib/features/trades/widgets/mostro_message_detail_widget.dart create mode 100644 lib/generated/l10n_en.dart diff --git a/devtools_options.yaml b/devtools_options.yaml index fa0b357c..4dcfde9f 100644 --- a/devtools_options.yaml +++ b/devtools_options.yaml @@ -1,3 +1,4 @@ description: This file stores settings for Dart & Flutter DevTools. documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states extensions: + - shared_preferences: true \ No newline at end of file diff --git a/flow.mermaid b/flow.mermaid new file mode 100644 index 00000000..37ecc3ec --- /dev/null +++ b/flow.mermaid @@ -0,0 +1,113 @@ +graph TD + +%% Order Creation +subgraph Order Creation + StartBuy["Buyer creates Buy Order"] + StartSell["Seller creates Sell Order"] + MostroPublishesOrder["Mostro publishes order (status: pending, kind:38383)"] +end + +%% Order Taken +subgraph Order Taken + SellerTakesBuy["Seller takes Buy Order"] + BuyerTakesSell["Buyer takes Sell Order"] + MostroWaitingPayment["Mostro: waiting-payment"] +end + +%% Invoice Handling +subgraph Invoice Handling + BuyerInvoice["Buyer provides LN Invoice"] + SellerPaysHoldInvoice["Seller pays Hold Invoice"] + OrderActive["Order becomes active (status: active)"] +end + +%% Fiat Payment +subgraph Fiat Payment + BuyerSendsFiat["Buyer sends fiat to Seller"] + FiatSent["Buyer notifies Mostro (fiat-sent)"] + SellerReleases["Seller releases sats"] +end + +%% Order Completion +subgraph Order Completion + MostroPaysBuyer["Mostro pays Buyer's LN Invoice"] + OrderSuccess["Order marked as success"] +end + +%% Dispute Handling +subgraph Dispute + InitiateDispute["Dispute initiated (active/fiat-sent orders)"] + DisputeInProgress["Dispute status: in-progress"] + AdminIntervention["Admin takes action"] +end + +%% User Ratings +subgraph Rating + MostroRequestsRating["Mostro requests user rating"] + UsersRateEachOther["Users rate each other"] + RatingReceived["Rating received (rate-received)"] +end + +%% Flow connections +StartBuy -->|new-order|MostroPublishesOrder +StartSell -->|new-order| MostroPublishesOrder +MostroPublishesOrder -->|pending| OrderAvailable["Order available for taking"] + +OrderAvailable -->|Buyer takes sell| BuyerInvoice +OrderAvailable -->|Seller takes buy| SellerPaysHoldInvoice + +BuyerInvoice --> SellerPaysHoldInvoice +SellerPaysHoldInvoice --> OrderActive + +OrderActive --> BuyerSendsFiat +BuyerSendsFiat -->|fiat-sent| BuyerConfirmsFiat["Mostro confirms fiat-sent"] +BuyerSendsFiat --> InitiateDispute + +BuyerSendsFiat -->|Seller confirms receipt| SellerReleasesSats["Seller releases sats"] + +SellerPaysHoldInvoice -->|Waiting| BuyerInvoice +SellerPaysHoldInvoice -->|paid| OrderActive + +SellerPaysHoldInvoice --> BuyerInvoice +BuyerInvoice --> OrderActive + +BuyerSendsFiat --> BuyerConfirmsFiat["Buyer notifies fiat sent (fiat-sent)"] +BuyerConfirmsFiat --> OrderActive + +OrderActive --> InitiateDispute +BuyerConfirmsFiat --> InitiateDispute + +InitiateDispute --> DisputeInProgress +DisputeInProgress --> AdminIntervention + +BuyerConfirmsFiat --> SellerReleases["Seller confirms fiat received and releases"] +SellerPaysHoldInvoice --> OrderActive + +SellerPaysHoldInvoice -->|waiting-buyer-invoice| BuyerInvoice +SellerPaysHoldInvoice -->|active| OrderActive + +BuyerSendsFiat --> BuyerConfirmsFiat +BuyerConfirmsFiat -->|fiat-sent| OrderActive + +OrderActive --> SellerReleases["Seller confirms fiat received (release)"] +SellerReleases --> MostroPaysBuyer["Mostro settles hold invoice"] +SellerReleases -->|settled-hold-invoice| MostroPaysBuyer["Mostro pays Buyer's LN Invoice"] + +SellerReleases --> MostroRequestsRating +MostroRequestsRating -->|payment settled| UsersRateEachOther["Users rate each other (1-5)"] +SellerReleases -->|payment succeeded| OrderSuccess["Order status: success"] + +%% Class Styling +classDef mostro fill:#FFD966,stroke:#333,stroke-width:1px +classDef buyer fill:#A9D5E2,stroke:#333,stroke-width:1px +classDef seller fill:#F7C6C7,stroke:#333,stroke-width:1px +classDef dispute fill:#FF9999,stroke:#333,stroke-width:1px +classDef rating fill:#C9DAF8,stroke:#333,stroke-width:1px +classDef admin fill:#C0C0C0,stroke:#333,stroke-width:1px + +class StartBuy,BuyerInvoice,BuyerSendsFiat,BuyerConfirmsFiat buyer +class StartSell,SellerTakesOrder,SellerPaysHoldInvoice,SellerReleases seller +class MostroPublishesOrder,OrderAvailable,OrderActive,OrderSuccess mostro +class InitiateDispute,DisputeInProgress dispute +class AdminIntervention admin +class MostroRequestsRating,UsersRateEachOther rating diff --git a/l10n.yaml b/l10n.yaml index c10580f6..7e7966e5 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,3 +1,6 @@ arb-dir: lib/l10n template-arb-file: intl_en.arb output-dir: lib/generated +output-localization-file: l10n.dart +output-class: S +synthetic-package: false diff --git a/lib/core/app.dart b/lib/core/app.dart index e87687d7..69ff51d1 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -43,7 +43,7 @@ class MostroApp extends ConsumerWidget { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - supportedLocales: S.delegate.supportedLocales, + supportedLocales: S.supportedLocales, ); }, loading: () => MaterialApp( diff --git a/lib/data/models/cant_do.dart b/lib/data/models/cant_do.dart index 31bc8766..8e552825 100644 --- a/lib/data/models/cant_do.dart +++ b/lib/data/models/cant_do.dart @@ -5,7 +5,7 @@ class CantDo implements Payload { final CantDoReason cantDoReason; factory CantDo.fromJson(Map json) { - return CantDo(cantDoReason: json['cant_do']); + return CantDo(cantDoReason: CantDoReason.fromString(json['cant_do'])); } CantDo({required this.cantDoReason}); diff --git a/lib/data/models/dispute.dart b/lib/data/models/dispute.dart index f4ea867a..4f37b094 100644 --- a/lib/data/models/dispute.dart +++ b/lib/data/models/dispute.dart @@ -1,17 +1,25 @@ import 'package:mostro_mobile/data/models/payload.dart'; class Dispute implements Payload { - final String orderId; + final String disputeId; - Dispute({required this.orderId}); + Dispute({required this.disputeId}); @override Map toJson() { return { - type: orderId, + type: disputeId, }; } + factory Dispute.fromJson(List json) { + final oid = json[0]; + return Dispute( + disputeId: oid, + ); + } + + @override String get type => 'dispute'; } diff --git a/lib/data/models/enums/cant_do_reason.dart b/lib/data/models/enums/cant_do_reason.dart index a9563914..8fbf46d7 100644 --- a/lib/data/models/enums/cant_do_reason.dart +++ b/lib/data/models/enums/cant_do_reason.dart @@ -1,38 +1,44 @@ enum CantDoReason { - invalidSignature('invalid-signature'), - invalidTradeIndex('invalid-trade-index'), - invalidAmount('invalid-amount'), - invalidInvoice('invalid-invoice'), - invalidPaymentRequest('invalid-payment-request'), - invalidPeer('invalid-peer'), - invalidRating('invalid-rating'), - invalidTextMessage('invalid-text-message'), - invalidOrderKind('invalid-order-kind'), - invalidOrderStatus('invalid-order-status'), - invalidPubkey('invalid-pubkey'), - invalidParameters('invalid-parameters'), - orderAlreadyCanceled('order-already-canceled'), - cantCreateUser('cant-create-user'), - isNotYourOrder('is-not-your-order'), - notAllowedByStatus('not-allowed-by-status'), - outOfRangeFiatAmount('out-of-range-fiat-amount'), - outOfRangeSatsAmount('out-of-range-sats-amount'), - isNotYourDispute('is-not-your-dispute'), - disputeCreationError('dispute-creation-error'), - notFound('not-found'), - invalidDisputeStatus('invalid-dispute-status'), - invalidAction('invalid-action'), - pendingOrderExists('pending-order-exists'); + invalidSignature('invalid_signature'), + invalidTradeIndex('invalid_trade_index'), + invalidAmount('invalid_amount'), + invalidInvoice('invalid_invoice'), + invalidPaymentRequest('invalid_payment_request'), + invalidPeer('invalid_peer'), + invalidRating('invalid_rating'), + invalidTextMessage('invalid_text_message'), + invalidOrderKind('invalid_order_kind'), + invalidOrderStatus('invalid_order_status'), + invalidPubkey('invalid_pubkey'), + invalidParameters('invalid_parameters'), + orderAlreadyCanceled('order_already_canceled'), + cantCreateUser('cant_create_user'), + isNotYourOrder('is_not_your_order'), + notAllowedByStatus('not_allowed_by_status'), + outOfRangeFiatAmount('out_of_range_fiat_amount'), + outOfRangeSatsAmount('out_of_range_sats_amount'), + isNotYourDispute('is_not_your_dispute'), + disputeCreationError('dispute_creation_error'), + notFound('not_found'), + invalidDisputeStatus('invalid_dispute_status'), + invalidAction('invalid_action'), + pendingOrderExists('pending_order_exists'); + + final String value; const CantDoReason(this.value); - final String value; - static CantDoReason? fromValue(String value) { - return CantDoReason.values - .where((e) => e.value == value) - .fold(null, (_, e) => e); // or firstWhere + catch - } + static final _valueMap = { + for (var cantDo in CantDoReason.values) cantDo.value: cantDo + }; + static CantDoReason fromString(String value) { + final cantDo = _valueMap[value]; + if (cantDo == null) { + throw ArgumentError('Invalid Can\'t Do Reason: $value'); + } + return cantDo; + } @override String toString() { return value; diff --git a/lib/data/models/enums/status.dart b/lib/data/models/enums/status.dart index 8d0b6c48..168f41a1 100644 --- a/lib/data/models/enums/status.dart +++ b/lib/data/models/enums/status.dart @@ -25,5 +25,9 @@ enum Status { orElse: () => throw ArgumentError('Invalid Status: $value'), ); } - + + @override + String toString() { + return value; + } } \ No newline at end of file diff --git a/lib/data/models/payload.dart b/lib/data/models/payload.dart index b34e1b88..f572f6bc 100644 --- a/lib/data/models/payload.dart +++ b/lib/data/models/payload.dart @@ -1,4 +1,5 @@ import 'package:mostro_mobile/data/models/cant_do.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/payment_request.dart'; import 'package:mostro_mobile/data/models/peer.dart'; @@ -16,7 +17,9 @@ abstract class Payload { return CantDo.fromJson(json); } else if (json.containsKey('peer')) { return Peer.fromJson(json['peer']); + } else if (json.containsKey('dispute')) { + return Dispute.fromJson(json['dispute']); } - throw UnsupportedError('Unknown content type'); + throw UnsupportedError('Unknown payload type'); } } diff --git a/lib/data/models/rating.dart b/lib/data/models/rating.dart index 1698de6d..3143a6eb 100644 --- a/lib/data/models/rating.dart +++ b/lib/data/models/rating.dart @@ -37,7 +37,7 @@ class Rating { } else { return Rating( totalReviews: 0, - totalRating: (json as int).toDouble(), + totalRating: (json[1]['total_reviews'] as int).toDouble(), lastRating: 0, maxRate: 0, minRate: 0, diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index e90b0e7c..8cbde891 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -137,4 +137,8 @@ class MostroRepository implements OrderRepository { Future releaseOrder(String orderId) async { await _mostroService.releaseOrder(orderId); } + + Future disputeOrder(String orderId) async { + await _mostroService.disputeOrder(orderId); + } } diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 0e0d3c7c..e81ef5f6 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -133,7 +133,7 @@ class AbstractOrderNotifier extends StateNotifier { break; case Action.released: notifProvider.showInformation(state.action, values: { - 'seller_npub': null, + 'seller_npub': '', }); default: diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 1b6ea9f6..b06e1b18 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -58,4 +58,9 @@ class OrderNotifier extends AbstractOrderNotifier { Future releaseOrder() async { await orderRepository.releaseOrder(orderId); } + + Future disputeOrder() async { + await orderRepository.disputeOrder(orderId); + } + } diff --git a/lib/features/order/screens/order_confirmation_screen.dart b/lib/features/order/screens/order_confirmation_screen.dart index 6b69ff6e..04d434da 100644 --- a/lib/features/order/screens/order_confirmation_screen.dart +++ b/lib/features/order/screens/order_confirmation_screen.dart @@ -15,29 +15,28 @@ class OrderConfirmationScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return Scaffold( backgroundColor: AppTheme.dark1, - appBar: OrderAppBar( - title: - 'Order Confirmed'), - body:CustomCard( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - S.of(context).newOrder('24'), - style: TextStyle(fontSize: 18, color: AppTheme.cream1), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton( - key: const Key('homeButton'), - onPressed: () => context.go('/'), - child: const Text('Back to Home'), - ), - ], + appBar: OrderAppBar(title: 'Order Confirmed'), + body: CustomCard( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + S.of(context)!.newOrder('24'), + style: TextStyle(fontSize: 18, color: AppTheme.cream1), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + key: const Key('homeButton'), + onPressed: () => context.go('/'), + child: const Text('Back to Home'), + ), + ], + ), ), ), - ),); + ); } } diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 4f91cec9..223c058c 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -146,8 +146,6 @@ class TakeOrderScreen extends ConsumerWidget { if (expiration.isAfter(now)) { countdown = expiration.difference(now); } - print(countdown); - return Column( children: [ diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index b1f606e0..e2185e5e 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -12,6 +12,7 @@ import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/features/trades/widgets/mostro_message_detail_widget.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; @@ -44,11 +45,15 @@ class TradeDetailScreen extends ConsumerWidget { _buildSellerAmount(ref, order), const SizedBox(height: 16), _buildOrderId(context), + const SizedBox(height: 16), + MostroMessageDetail(order: order), const SizedBox(height: 24), _buildCountDownTime(order.expirationDate), const SizedBox(height: 36), - // Pass the full order to the action buttons widget. - _buildActionButtons(context, ref, order), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: _buildActionButtons(context, ref, order), + ), ], ), ); @@ -143,9 +148,8 @@ class TradeDetailScreen extends ConsumerWidget { Duration countdown = Duration(hours: 0); final now = DateTime.now(); if (expiration.isAfter(now)) { - countdown = now.difference(expiration); + countdown = expiration.difference(now); } - print(countdown); return Column( children: [ @@ -159,59 +163,137 @@ class TradeDetailScreen extends ConsumerWidget { ); } - Widget _buildActionButtons( - BuildContext context, WidgetRef ref, NostrEvent order) { + Widget _buildCancelButton(WidgetRef ref) { final orderDetailsNotifier = - ref.read(orderNotifierProvider(order.orderId!).notifier); - final message = ref.watch(orderNotifierProvider(order.orderId!)); + ref.read(orderNotifierProvider(orderId).notifier); - final showCancel = - (order.status == Status.pending || order.status == Status.inProgress); + return ElevatedButton( + onPressed: () async { + await orderDetailsNotifier.cancelOrder(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.red1, + ), + child: const Text('CANCEL'), + ); + } - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton( - onPressed: () { - context.pop(); - }, - style: AppTheme.theme.outlinedButtonTheme.style, - child: const Text('CLOSE'), - ), - const SizedBox(width: 16), - if (showCancel) - ElevatedButton( - onPressed: () async { - await orderDetailsNotifier.cancelOrder(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - ), - child: const Text('CANCEL'), + Widget _buildDisputeButton(WidgetRef ref) { + final orderDetailsNotifier = + ref.read(orderNotifierProvider(orderId).notifier); + + return ElevatedButton( + onPressed: () async { + await orderDetailsNotifier.disputeOrder(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.red1, + ), + child: const Text('DISPUTE'), + ); + } + + Widget _buildCloseButton(BuildContext context) { + return OutlinedButton( + onPressed: () { + context.pop(); + }, + style: AppTheme.theme.outlinedButtonTheme.style, + child: const Text('CLOSE'), + ); + } + + Widget _buildRateButton(BuildContext context) { + return OutlinedButton( + onPressed: () { + context.go('/rate_user'); + }, + style: AppTheme.theme.outlinedButtonTheme.style, + child: const Text('RATE'), + ); + } + + List _buildActionButtons( + BuildContext context, WidgetRef ref, NostrEvent order) { + final orderDetailsNotifier = + ref.read(orderNotifierProvider(orderId).notifier); + final message = ref.watch(orderNotifierProvider(orderId)); + + switch (order.status) { + case Status.waitingBuyerInvoice: + case Status.settledHoldInvoice: + case Status.active: + return [ + _buildCloseButton( + context, ), - const SizedBox(width: 16), - if (message.action == actions.Action.holdInvoicePaymentAccepted) - ElevatedButton( - onPressed: () async { - await orderDetailsNotifier.sendFiatSent(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, + if (message.action != actions.Action.disputeInitiatedByYou && + message.action != actions.Action.disputeInitiatedByPeer) + _buildDisputeButton(ref), + if (message.action == actions.Action.addInvoice) + ElevatedButton( + onPressed: () async { + context.push('/add_invoice/$orderId'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('FIAT SENT'), ), - child: const Text('FIAT SENT'), - ), - if (message.action == actions.Action.buyerTookOrder) - ElevatedButton( - onPressed: () async { - await orderDetailsNotifier.releaseOrder(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, + if (message.action == actions.Action.holdInvoicePaymentAccepted) + ElevatedButton( + onPressed: () async { + await orderDetailsNotifier.sendFiatSent(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('FIAT SENT'), ), - child: const Text('RELEASE SATS'), - ), - ], - ); + if (message.action == actions.Action.buyerTookOrder) + ElevatedButton( + onPressed: () async { + await orderDetailsNotifier.releaseOrder(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('RELEASE SATS'), + ), + ]; + case Status.fiatSent: + return [ + _buildCloseButton(context), + _buildDisputeButton(ref), + ]; + case Status.cooperativelyCanceled: + return [ + _buildCloseButton(context), + if (message.action == actions.Action.cooperativeCancelInitiatedByPeer) + _buildCancelButton(ref), + ]; + case Status.success: + return [ + _buildCloseButton(context), + _buildRateButton(context), + ]; + case Status.pending: + case Status.waitingPayment: + return [ + _buildCloseButton(context), + _buildCancelButton(ref), + ]; + case Status.expired: + case Status.dispute: + case Status.completedByAdmin: + case Status.canceledByAdmin: + case Status.settledByAdmin: + case Status.canceled: + case Status.inProgress: + return [ + _buildCloseButton(context), + ]; + } } String formatDateTime(DateTime dt) { diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart new file mode 100644 index 00000000..4e850151 --- /dev/null +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -0,0 +1,274 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/data/models/cant_do.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; +import 'package:mostro_mobile/data/models/enums/cant_do_reason.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/peer.dart'; +import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/widgets/custom_card.dart'; + +class MostroMessageDetail extends ConsumerWidget { + final NostrEvent order; + + const MostroMessageDetail({super.key, required this.order}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Retrieve the MostroMessage using the order's orderId + final mostroMessage = ref.watch(orderNotifierProvider(order.orderId!)); + + // Map the action enum to the corresponding i10n string. + String actionText; + switch (mostroMessage.action) { + case actions.Action.newOrder: + final expHrs = + ref.read(orderRepositoryProvider).mostroInstance?.expiration ?? + '24'; + actionText = S.of(context)!.newOrder(int.tryParse(expHrs) ?? 24); + break; + case actions.Action.canceled: + actionText = S.of(context)!.canceled(order.orderId!); + break; + case actions.Action.payInvoice: + final expSecs = ref + .read(orderRepositoryProvider) + .mostroInstance + ?.expirationSeconds ?? + 900; + actionText = S.of(context)!.payInvoice( + order.amount!, order.currency!, order.fiatAmount.minimum, expSecs); + break; + case actions.Action.addInvoice: + final expSecs = ref + .read(orderRepositoryProvider) + .mostroInstance + ?.expirationSeconds ?? + 900; + actionText = S.of(context)!.addInvoice( + order.amount!, order.currency!, order.fiatAmount.minimum, expSecs); + break; + case actions.Action.waitingSellerToPay: + final expSecs = ref + .read(orderRepositoryProvider) + .mostroInstance + ?.expirationSeconds ?? + 900; + actionText = S.of(context)!.waitingSellerToPay(order.orderId!, expSecs); + break; + case actions.Action.waitingBuyerInvoice: + final expSecs = ref + .read(orderRepositoryProvider) + .mostroInstance + ?.expirationSeconds ?? + 900; + actionText = S.of(context)!.waitingBuyerInvoice(expSecs); + break; + case actions.Action.buyerInvoiceAccepted: + actionText = S.of(context)!.buyerInvoiceAccepted; + break; + case actions.Action.holdInvoicePaymentAccepted: + final payload = mostroMessage.getPayload(); + actionText = S.of(context)!.holdInvoicePaymentAccepted( + payload!.fiatAmount, + payload.fiatCode, + payload.paymentMethod, + payload.sellerTradePubkey!, + ); + break; + case actions.Action.buyerTookOrder: + final payload = mostroMessage.getPayload(); + actionText = S.of(context)!.buyerTookOrder(payload!.buyerTradePubkey!, + payload.fiatCode, payload.fiatAmount, payload.paymentMethod); + break; + case actions.Action.fiatSentOk: + final payload = mostroMessage.getPayload(); + actionText = S.of(context)!.fiatSentOkBuyer(payload!.publicKey); + //actionText = S.of(context)!.fiatSentOkSeller; + break; + case actions.Action.released: + final payload = mostroMessage.getPayload(); + actionText = S.of(context)!.released(payload!.sellerTradePubkey!); + break; + case actions.Action.purchaseCompleted: + actionText = S.of(context)!.purchaseCompleted; + break; + case actions.Action.holdInvoicePaymentSettled: + final payload = mostroMessage.getPayload(); + actionText = S + .of(context)! + .holdInvoicePaymentSettled(payload!.buyerTradePubkey!); + break; + case actions.Action.rate: + actionText = S.of(context)!.rate; + break; + case actions.Action.rateReceived: + actionText = S.of(context)!.rateReceived; + break; + case actions.Action.cooperativeCancelInitiatedByYou: + actionText = + S.of(context)!.cooperativeCancelInitiatedByYou(order.orderId!); + break; + case actions.Action.cooperativeCancelInitiatedByPeer: + actionText = + S.of(context)!.cooperativeCancelInitiatedByPeer(order.orderId!); + break; + case actions.Action.cooperativeCancelAccepted: + actionText = S.of(context)!.cooperativeCancelAccepted(order.orderId!); + break; + case actions.Action.disputeInitiatedByYou: + final payload = mostroMessage.getPayload(); + actionText = S + .of(context)! + .disputeInitiatedByYou(order.orderId!, payload!.disputeId); + break; + case actions.Action.disputeInitiatedByPeer: + final payload = mostroMessage.getPayload(); + actionText = S + .of(context)! + .disputeInitiatedByPeer(order.orderId!, payload!.disputeId); + break; + case actions.Action.adminTookDispute: + //actionText = S.of(context)!.adminTookDisputeAdmin(''); + actionText = S.of(context)!.adminTookDisputeUsers('{admin token}'); + break; + case actions.Action.adminCanceled: + //actionText = S.of(context)!.adminCanceledAdmin(''); + actionText = S.of(context)!.adminCanceledUsers(order.orderId!); + break; + case actions.Action.adminSettled: + //actionText = S.of(context)!.adminSettledAdmin; + actionText = S.of(context)!.adminSettledUsers(order.orderId!); + break; + case actions.Action.paymentFailed: + actionText = S.of(context)!.paymentFailed('{attempts}', '{retries}'); + break; + case actions.Action.invoiceUpdated: + actionText = S.of(context)!.invoiceUpdated; + break; + case actions.Action.holdInvoicePaymentCanceled: + actionText = S.of(context)!.holdInvoicePaymentCanceled; + break; + case actions.Action.cantDo: + final cantDo = mostroMessage.getPayload(); + switch (cantDo!.cantDoReason) { + case CantDoReason.invalidSignature: + actionText = S.of(context)!.invalidSignature; + break; + case CantDoReason.invalidTradeIndex: + actionText = S.of(context)!.invalidTradeIndex; + break; + case CantDoReason.invalidAmount: + actionText = S.of(context)!.invalidAmount; + break; + case CantDoReason.invalidInvoice: + actionText = S.of(context)!.invalidInvoice; + break; + case CantDoReason.invalidPaymentRequest: + actionText = S.of(context)!.invalidPaymentRequest; + break; + case CantDoReason.invalidPeer: + actionText = S.of(context)!.invalidPeer; + break; + case CantDoReason.invalidRating: + actionText = S.of(context)!.invalidRating; + break; + case CantDoReason.invalidTextMessage: + actionText = S.of(context)!.invalidTextMessage; + break; + case CantDoReason.invalidOrderKind: + actionText = S.of(context)!.invalidOrderKind; + break; + case CantDoReason.invalidOrderStatus: + actionText = S.of(context)!.invalidOrderStatus; + break; + case CantDoReason.invalidPubkey: + actionText = S.of(context)!.invalidPubkey; + break; + case CantDoReason.invalidParameters: + actionText = S.of(context)!.invalidParameters; + break; + case CantDoReason.orderAlreadyCanceled: + actionText = S.of(context)!.orderAlreadyCanceled; + break; + case CantDoReason.cantCreateUser: + actionText = S.of(context)!.cantCreateUser; + break; + case CantDoReason.isNotYourOrder: + actionText = S.of(context)!.isNotYourOrder; + break; + case CantDoReason.notAllowedByStatus: + actionText = + S.of(context)!.notAllowedByStatus(order.orderId!, order.status); + break; + case CantDoReason.outOfRangeFiatAmount: + actionText = + S.of(context)!.outOfRangeFiatAmount('{fiat_min}', '{fiat_max}'); + break; + case CantDoReason.outOfRangeSatsAmount: + final mostroInstance = + ref.read(orderRepositoryProvider).mostroInstance; + actionText = S.of(context)!.outOfRangeSatsAmount( + mostroInstance!.maxOrderAmount, mostroInstance.minOrderAmount); + break; + case CantDoReason.isNotYourDispute: + actionText = S.of(context)!.isNotYourDispute; + break; + case CantDoReason.disputeCreationError: + actionText = S.of(context)!.disputeCreationError; + break; + case CantDoReason.notFound: + actionText = S.of(context)!.notFound; + break; + case CantDoReason.invalidDisputeStatus: + actionText = S.of(context)!.invalidDisputeStatus; + break; + case CantDoReason.invalidAction: + actionText = S.of(context)!.invalidAction; + break; + case CantDoReason.pendingOrderExists: + actionText = S.of(context)!.pendingOrderExists; + break; + } + break; + case actions.Action.adminAddSolver: + actionText = S.of(context)!.adminAddSolver('{admin_solver}'); + break; + default: + actionText = ''; + } + + return CustomCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const CircleAvatar( + backgroundColor: AppTheme.grey2, + foregroundImage: AssetImage('assets/images/launcher-icon.png'), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + actionText, + style: AppTheme.theme.textTheme.bodyLarge, + ), + const SizedBox(height: 16), + Text('${order.status} - ${mostroMessage.action}'), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/generated/action_localizations.dart b/lib/generated/action_localizations.dart index 9d106400..a83ecb5b 100644 --- a/lib/generated/action_localizations.dart +++ b/lib/generated/action_localizations.dart @@ -38,11 +38,11 @@ extension ActionLocalizationX on S { return purchaseCompleted; case Action.holdInvoicePaymentAccepted: return holdInvoicePaymentAccepted( - placeholders['seller_npub'], - placeholders['id'], - placeholders['fiat_code'], - placeholders['fiat_amount'], - placeholders['payment_method']); + placeholders['fiat_amount'], + placeholders['fiat_code'], + placeholders['payment_method'], + placeholders['seller_npub'], + ); case Action.holdInvoicePaymentSettled: return holdInvoicePaymentSettled(placeholders['buyer_npub']); case Action.holdInvoicePaymentCanceled: diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 5be88bcc..0ed88b5e 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -1,494 +1,462 @@ -// 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) { +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'l10n_en.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of S +/// returned by `S.of(context)`. +/// +/// Applications need to include `S.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'generated/l10n.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: S.localizationsDelegates, +/// supportedLocales: S.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the S.supportedLocales +/// property. +abstract class S { + S(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static S? of(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 newOrder(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: 'newOrder', - 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 payInvoice(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: 'payInvoice', - 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 addInvoice(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: 'addInvoice', - 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 waitingSellerToPay(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: 'waitingSellerToPay', - 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 waitingBuyerInvoice(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: 'waitingBuyerInvoice', - desc: '', - args: [expiration_seconds], - ); - } - - /// `Invoice has been successfully saved!` - String get buyerInvoiceAccepted { - return Intl.message( - 'Invoice has been successfully saved!', - name: 'buyerInvoiceAccepted', - 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 holdInvoicePaymentAccepted(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: 'holdInvoicePaymentAccepted', - 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 buyerTookOrder(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: 'buyerTookOrder', - 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 fiatSentOkBuyer(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: 'fiatSentOkBuyer', - 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 fiatSentOkSeller(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: 'fiatSentOkSeller', - 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 purchaseCompleted { - return Intl.message( - 'Your satoshis purchase has been completed successfully. I have paid your invoice, enjoy sound money!', - name: 'purchaseCompleted', - desc: '', - args: [], - ); - } - - /// `Your Sats sale has been completed after confirming the payment from {buyer_npub}.` - String holdInvoicePaymentSettled(Object buyer_npub) { - return Intl.message( - 'Your Sats sale has been completed after confirming the payment from $buyer_npub.', - name: 'holdInvoicePaymentSettled', - 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 rateReceived { - return Intl.message( - 'Rating successfully saved!', - name: 'rateReceived', - 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 cooperativeCancelInitiatedByYou(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: 'cooperativeCancelInitiatedByYou', - 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 cooperativeCancelInitiatedByPeer(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: 'cooperativeCancelInitiatedByPeer', - desc: '', - args: [id], - ); - } - - /// `Order {id} has been successfully cancelled!` - String cooperativeCancelAccepted(Object id) { - return Intl.message( - 'Order $id has been successfully cancelled!', - name: 'cooperativeCancelAccepted', - 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 disputeInitiatedByYou(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: 'disputeInitiatedByYou', - 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 disputeInitiatedByPeer(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: 'disputeInitiatedByPeer', - 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 adminTookDisputeAdmin(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: 'adminTookDisputeAdmin', - 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 adminTookDisputeUsers(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: 'adminTookDisputeUsers', - desc: '', - args: [admin_npub], - ); - } - - /// `You have cancelled the order ID: {id}!` - String adminCanceledAdmin(Object id) { - return Intl.message( - 'You have cancelled the order ID: $id!', - name: 'adminCanceledAdmin', - desc: '', - args: [id], - ); - } - - /// `Admin has cancelled the order ID: {id}!` - String adminCanceledUsers(Object id) { - return Intl.message( - 'Admin has cancelled the order ID: $id!', - name: 'adminCanceledUsers', - desc: '', - args: [id], - ); - } - - /// `You have completed the order ID: {id}!` - String adminSettledAdmin(Object id) { - return Intl.message( - 'You have completed the order ID: $id!', - name: 'adminSettledAdmin', - desc: '', - args: [id], - ); - } - - /// `Admin has completed the order ID: {id}!` - String adminSettledUsers(Object id) { - return Intl.message( - 'Admin has completed the order ID: $id!', - name: 'adminSettledUsers', - desc: '', - args: [id], - ); - } - - /// `This dispute was not assigned to you!` - String get isNotYourDispute { - return Intl.message( - 'This dispute was not assigned to you!', - name: 'isNotYourDispute', - desc: '', - args: [], - ); - } - - /// `Dispute not found.` - String get notFound { - return Intl.message( - 'Dispute not found.', - name: 'notFound', - 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 paymentFailed( - 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: 'paymentFailed', - desc: '', - args: [payment_attempts, payment_retries_interval], - ); - } - - /// `Invoice has been successfully updated!` - String get invoiceUpdated { - return Intl.message( - 'Invoice has been successfully updated!', - name: 'invoiceUpdated', - desc: '', - args: [], - ); - } - - /// `The invoice was cancelled; your Sats will be available in your wallet again.` - String get holdInvoicePaymentCanceled { - return Intl.message( - 'The invoice was cancelled; your Sats will be available in your wallet again.', - name: 'holdInvoicePaymentCanceled', - desc: '', - args: [], - ); - } - - /// `You are not allowed to {action} for this order!` - String cantDo(Object action) { - return Intl.message( - 'You are not allowed to $action for this order!', - name: 'cantDo', - desc: '', - args: [action], - ); - } - - /// `You have successfully added the solver {npub}.` - String adminAddSolver(Object npub) { - return Intl.message( - 'You have successfully added the solver $npub.', - name: 'adminAddSolver', - desc: '', - args: [npub], - ); - } - - /// `You did not create this order and are not authorized to {action} it.` - String isNotYourOrder(Object action) { - return Intl.message( - 'You did not create this order and are not authorized to $action it.', - name: 'isNotYourOrder', - desc: '', - args: [action], - ); - } - - /// `You are not allowed to {action} because order Id {id} status is {order_status}.` - String notAllowedByStatus(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: 'notAllowedByStatus', - 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 outOfRangeFiatAmount(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: 'outOfRangeFiatAmount', - desc: '', - args: [min_amount, max_amount], - ); - } + static const LocalizationsDelegate delegate = _SDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en') + ]; + + /// No description provided for @newOrder. + /// + /// In en, this message translates to: + /// **'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 newOrder(Object expiration_hours); + + /// No description provided for @canceled. + /// + /// In en, this message translates to: + /// **'You have canceled the order ID: {id}.'** + String canceled(Object id); + + /// No description provided for @payInvoice. + /// + /// In en, this message translates to: + /// **'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 canceled.'** + String payInvoice(Object amount, Object expiration_seconds, Object fiat_amount, Object fiat_code); + + /// No description provided for @addInvoice. + /// + /// In en, this message translates to: + /// **'Please send me an invoice for {amount} satoshis equivalent to {fiat_code} {fiat_amount}. This is where I will send the funds upon trade completion. If you don\'t provide the invoice within {expiration_seconds}, the trade will be canceled.'** + String addInvoice(Object amount, Object expiration_seconds, Object fiat_amount, Object fiat_code); + + /// No description provided for @waitingSellerToPay. + /// + /// In en, this message translates to: + /// **'Please wait. I’ve sent a payment request to the seller to send the Sats for the order ID {id}. If the seller doesn’t complete the payment within {expiration_seconds}, the trade will be canceled.'** + String waitingSellerToPay(Object expiration_seconds, Object id); + + /// No description provided for @waitingBuyerInvoice. + /// + /// In en, this message translates to: + /// **'Payment received! Your Sats are now \'held\' in your wallet. I’ve requested the buyer to provide an invoice. If they don’t do so within {expiration_seconds}, your Sats will return to your wallet, and the trade will be canceled.'** + String waitingBuyerInvoice(Object expiration_seconds); + + /// No description provided for @buyerInvoiceAccepted. + /// + /// In en, this message translates to: + /// **'The invoice has been successfully saved.'** + String get buyerInvoiceAccepted; + + /// No description provided for @holdInvoicePaymentAccepted. + /// + /// In en, this message translates to: + /// **'Contact the seller at {seller_npub} to arrange how to send {fiat_code} {fiat_amount} using {payment_method}. Once you send the fiat money, please notify me with fiat-sent.'** + String holdInvoicePaymentAccepted(Object fiat_amount, Object fiat_code, Object payment_method, Object seller_npub); + + /// No description provided for @buyerTookOrder. + /// + /// In en, this message translates to: + /// **'Contact the buyer at {buyer_npub} to inform them how to send {fiat_code} {fiat_amount} through {payment_method}. You’ll be notified when the buyer confirms the fiat payment. Afterward, 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 buyerTookOrder(Object buyer_npub, Object fiat_amount, Object fiat_code, Object payment_method); + + /// No description provided for @fiatSentOkBuyer. + /// + /// In en, this message translates to: + /// **'I have informed {seller_npub} that you sent the fiat money. If the seller confirms receipt, they will release the funds. If they refuse, you can open a dispute.'** + String fiatSentOkBuyer(Object seller_npub); + + /// No description provided for @fiatSentOkSeller. + /// + /// In en, this message translates to: + /// **'{buyer_npub} has informed you that they sent 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 fiatSentOkSeller(Object buyer_npub); + + /// No description provided for @released. + /// + /// In en, this message translates to: + /// **'{seller_npub} has released the Sats! Expect your invoice to be paid shortly. Ensure your wallet is online to receive via Lightning Network.'** + String released(Object seller_npub); + + /// No description provided for @purchaseCompleted. + /// + /// In en, this message translates to: + /// **'Your purchase of Bitcoin has been completed successfully. I have paid your invoice; enjoy sound money!'** + String get purchaseCompleted; + + /// No description provided for @holdInvoicePaymentSettled. + /// + /// In en, this message translates to: + /// **'Your Sats sale has been completed after confirming the payment from {buyer_npub}.'** + String holdInvoicePaymentSettled(Object buyer_npub); + + /// No description provided for @rate. + /// + /// In en, this message translates to: + /// **'Please rate your counterparty'** + String get rate; + + /// No description provided for @rateReceived. + /// + /// In en, this message translates to: + /// **'Rating successfully saved!'** + String get rateReceived; + + /// No description provided for @cooperativeCancelInitiatedByYou. + /// + /// In en, this message translates to: + /// **'You have initiated the cancellation of the order ID: {id}. Your counterparty must agree. If they do not respond, you can open a dispute. Note that no administrator will contact you regarding this cancellation unless you open a dispute first.'** + String cooperativeCancelInitiatedByYou(Object id); + + /// No description provided for @cooperativeCancelInitiatedByPeer. + /// + /// In en, this message translates to: + /// **'Your counterparty wants to cancel order ID: {id}. If you agree, please send me cancel-order-message. Note that no administrator will contact you regarding this cancellation unless you open a dispute first.'** + String cooperativeCancelInitiatedByPeer(Object id); + + /// No description provided for @cooperativeCancelAccepted. + /// + /// In en, this message translates to: + /// **'Order {id} has been successfully canceled!'** + String cooperativeCancelAccepted(Object id); + + /// No description provided for @disputeInitiatedByYou. + /// + /// In en, this message translates to: + /// **'You have initiated a dispute for order Id: {id}. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. Your dispute token is: {user_token}.'** + String disputeInitiatedByYou(Object id, Object user_token); + + /// No description provided for @disputeInitiatedByPeer. + /// + /// In en, this message translates to: + /// **'Your counterparty has initiated a dispute for order Id: {id}. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. Your dispute token is: {user_token}.'** + String disputeInitiatedByPeer(Object id, Object user_token); + + /// No description provided for @adminTookDisputeAdmin. + /// + /// In en, this message translates to: + /// **'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 adminTookDisputeAdmin(Object details); + + /// No description provided for @adminTookDisputeUsers. + /// + /// In en, this message translates to: + /// **'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 adminTookDisputeUsers(Object admin_npub); + + /// No description provided for @adminCanceledAdmin. + /// + /// In en, this message translates to: + /// **'You have canceled the order ID: {id}.'** + String adminCanceledAdmin(Object id); + + /// No description provided for @adminCanceledUsers. + /// + /// In en, this message translates to: + /// **'Admin has canceled the order ID: {id}.'** + String adminCanceledUsers(Object id); + + /// No description provided for @adminSettledAdmin. + /// + /// In en, this message translates to: + /// **'You have completed the order ID: {id}.'** + String adminSettledAdmin(Object id); + + /// No description provided for @adminSettledUsers. + /// + /// In en, this message translates to: + /// **'Admin has completed the order ID: {id}.'** + String adminSettledUsers(Object id); + + /// No description provided for @paymentFailed. + /// + /// In en, this message translates to: + /// **'I couldn’t send the Sats. I will try {payment_attempts} more times in {payment_retries_interval} minutes. Please ensure your node or wallet is online.'** + String paymentFailed(Object payment_attempts, Object payment_retries_interval); + + /// No description provided for @invoiceUpdated. + /// + /// In en, this message translates to: + /// **'The invoice has been successfully updated!'** + String get invoiceUpdated; + + /// No description provided for @holdInvoicePaymentCanceled. + /// + /// In en, this message translates to: + /// **'The invoice was canceled; your Sats are available in your wallet again.'** + String get holdInvoicePaymentCanceled; + + /// No description provided for @cantDo. + /// + /// In en, this message translates to: + /// **'You are not allowed to {action} for this order!'** + String cantDo(Object action); + + /// No description provided for @adminAddSolver. + /// + /// In en, this message translates to: + /// **'You have successfully added the solver {npub}.'** + String adminAddSolver(Object npub); + + /// No description provided for @invalidSignature. + /// + /// In en, this message translates to: + /// **'The action cannot be completed because the signature is invalid.'** + String get invalidSignature; + + /// No description provided for @invalidTradeIndex. + /// + /// In en, this message translates to: + /// **'The provided trade index is invalid. Please ensure your client is synchronized and try again.'** + String get invalidTradeIndex; + + /// No description provided for @invalidAmount. + /// + /// In en, this message translates to: + /// **'The provided amount is invalid. Please verify it and try again.'** + String get invalidAmount; + + /// No description provided for @invalidInvoice. + /// + /// In en, this message translates to: + /// **'The provided Lightning invoice is invalid. Please check the invoice details and try again.'** + String get invalidInvoice; + + /// No description provided for @invalidPaymentRequest. + /// + /// In en, this message translates to: + /// **'The payment request is invalid or cannot be processed.'** + String get invalidPaymentRequest; + + /// No description provided for @invalidPeer. + /// + /// In en, this message translates to: + /// **'You are not authorized to perform this action.'** + String get invalidPeer; + + /// No description provided for @invalidRating. + /// + /// In en, this message translates to: + /// **'The rating value is invalid or out of range.'** + String get invalidRating; + + /// No description provided for @invalidTextMessage. + /// + /// In en, this message translates to: + /// **'The text message is invalid or contains prohibited content.'** + String get invalidTextMessage; + + /// No description provided for @invalidOrderKind. + /// + /// In en, this message translates to: + /// **'The order kind is invalid.'** + String get invalidOrderKind; + + /// No description provided for @invalidOrderStatus. + /// + /// In en, this message translates to: + /// **'The action cannot be completed due to the current order status.'** + String get invalidOrderStatus; + + /// No description provided for @invalidPubkey. + /// + /// In en, this message translates to: + /// **'The action cannot be completed because the public key is invalid.'** + String get invalidPubkey; + + /// No description provided for @invalidParameters. + /// + /// In en, this message translates to: + /// **'The action cannot be completed due to invalid parameters. Please review the provided values and try again.'** + String get invalidParameters; + + /// No description provided for @orderAlreadyCanceled. + /// + /// In en, this message translates to: + /// **'The action cannot be completed because the order has already been canceled.'** + String get orderAlreadyCanceled; + + /// No description provided for @cantCreateUser. + /// + /// In en, this message translates to: + /// **'The action cannot be completed because the user could not be created.'** + String get cantCreateUser; + + /// No description provided for @isNotYourOrder. + /// + /// In en, this message translates to: + /// **'This order does not belong to you.'** + String get isNotYourOrder; + + /// No description provided for @notAllowedByStatus. + /// + /// In en, this message translates to: + /// **'The action cannot be completed because order Id {id} status is {order_status}.'** + String notAllowedByStatus(Object id, Object order_status); + + /// No description provided for @outOfRangeFiatAmount. + /// + /// In en, this message translates to: + /// **'The requested fiat amount is outside the acceptable range ({min_amount}–{max_amount}).'** + String outOfRangeFiatAmount(Object max_amount, Object min_amount); + + /// No description provided for @outOfRangeSatsAmount. + /// + /// In en, this message translates to: + /// **'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 outOfRangeSatsAmount(Object max_order_amount, Object min_order_amount); + + /// No description provided for @isNotYourDispute. + /// + /// In en, this message translates to: + /// **'This dispute is not assigned to you.'** + String get isNotYourDispute; + + /// No description provided for @disputeCreationError. + /// + /// In en, this message translates to: + /// **'A dispute cannot be created for this order.'** + String get disputeCreationError; + + /// No description provided for @notFound. + /// + /// In en, this message translates to: + /// **'The requested dispute could not be found.'** + String get notFound; + + /// No description provided for @invalidDisputeStatus. + /// + /// In en, this message translates to: + /// **'The dispute status is invalid.'** + String get invalidDisputeStatus; + + /// No description provided for @invalidAction. + /// + /// In en, this message translates to: + /// **'The requested action is invalid.'** + String get invalidAction; + + /// No description provided for @pendingOrderExists. + /// + /// In en, this message translates to: + /// **'A pending order already exists.'** + String get pendingOrderExists; +} - /// `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 incorrectInvoiceAmountBuyerNewOrder { - 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: 'incorrectInvoiceAmountBuyerNewOrder', - desc: '', - args: [], - ); - } +class _SDelegate extends LocalizationsDelegate { + const _SDelegate(); - /// `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 incorrectInvoiceAmountBuyerAddInvoice(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: 'incorrectInvoiceAmountBuyerAddInvoice', - desc: '', - args: [amount], - ); + @override + Future load(Locale locale) { + return SynchronousFuture(lookupS(locale)); } - /// `The specified Sats amount is invalid.` - String get invalidSatsAmount { - return Intl.message( - 'The specified Sats amount is invalid.', - name: 'invalidSatsAmount', - desc: '', - args: [], - ); - } + @override + bool isSupported(Locale locale) => ['en'].contains(locale.languageCode); - /// `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 outOfRangeSatsAmount( - 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: 'outOfRangeSatsAmount', - desc: '', - args: [min_order_amount, max_order_amount], - ); - } + @override + bool shouldReload(_SDelegate old) => false; } -class AppLocalizationDelegate extends LocalizationsDelegate { - const AppLocalizationDelegate(); +S lookupS(Locale locale) { - 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; + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': return SEn(); } + + throw FlutterError( + 'S.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.' + ); } diff --git a/lib/generated/l10n_en.dart b/lib/generated/l10n_en.dart new file mode 100644 index 00000000..7cc96da6 --- /dev/null +++ b/lib/generated/l10n_en.dart @@ -0,0 +1,236 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'l10n.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class SEn extends S { + SEn([String locale = 'en']) : super(locale); + + @override + String newOrder(Object expiration_hours) { + return '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.'; + } + + @override + String canceled(Object id) { + return 'You have canceled the order ID: $id.'; + } + + @override + String payInvoice(Object amount, Object expiration_seconds, Object fiat_amount, Object fiat_code) { + return '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 canceled.'; + } + + @override + String addInvoice(Object amount, Object expiration_seconds, Object fiat_amount, Object fiat_code) { + return 'Please send me an invoice for $amount satoshis equivalent to $fiat_code $fiat_amount. This is where I will send the funds upon trade completion. If you don\'t provide the invoice within $expiration_seconds, the trade will be canceled.'; + } + + @override + String waitingSellerToPay(Object expiration_seconds, Object id) { + return 'Please wait. I’ve sent a payment request to the seller to send the Sats for the order ID $id. If the seller doesn’t complete the payment within $expiration_seconds, the trade will be canceled.'; + } + + @override + String waitingBuyerInvoice(Object expiration_seconds) { + return 'Payment received! Your Sats are now \'held\' in your wallet. I’ve requested the buyer to provide an invoice. If they don’t do so within $expiration_seconds, your Sats will return to your wallet, and the trade will be canceled.'; + } + + @override + String get buyerInvoiceAccepted => 'The invoice has been successfully saved.'; + + @override + String holdInvoicePaymentAccepted(Object fiat_amount, Object fiat_code, Object payment_method, Object seller_npub) { + return 'Contact the seller at $seller_npub to arrange how to send $fiat_code $fiat_amount using $payment_method. Once you send the fiat money, please notify me with fiat-sent.'; + } + + @override + String buyerTookOrder(Object buyer_npub, Object fiat_amount, Object fiat_code, Object payment_method) { + return 'Contact the buyer at $buyer_npub to inform them how to send $fiat_code $fiat_amount through $payment_method. You’ll be notified when the buyer confirms the fiat payment. Afterward, 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.'; + } + + @override + String fiatSentOkBuyer(Object seller_npub) { + return 'I have informed $seller_npub that you sent the fiat money. If the seller confirms receipt, they will release the funds. If they refuse, you can open a dispute.'; + } + + @override + String fiatSentOkSeller(Object buyer_npub) { + return '$buyer_npub has informed you that they sent 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.'; + } + + @override + String released(Object seller_npub) { + return '$seller_npub has released the Sats! Expect your invoice to be paid shortly. Ensure your wallet is online to receive via Lightning Network.'; + } + + @override + String get purchaseCompleted => 'Your purchase of Bitcoin has been completed successfully. I have paid your invoice; enjoy sound money!'; + + @override + String holdInvoicePaymentSettled(Object buyer_npub) { + return 'Your Sats sale has been completed after confirming the payment from $buyer_npub.'; + } + + @override + String get rate => 'Please rate your counterparty'; + + @override + String get rateReceived => 'Rating successfully saved!'; + + @override + String cooperativeCancelInitiatedByYou(Object id) { + return 'You have initiated the cancellation of the order ID: $id. Your counterparty must agree. If they do not respond, you can open a dispute. Note that no administrator will contact you regarding this cancellation unless you open a dispute first.'; + } + + @override + String cooperativeCancelInitiatedByPeer(Object id) { + return 'Your counterparty wants to cancel order ID: $id. If you agree, please send me cancel-order-message. Note that no administrator will contact you regarding this cancellation unless you open a dispute first.'; + } + + @override + String cooperativeCancelAccepted(Object id) { + return 'Order $id has been successfully canceled!'; + } + + @override + String disputeInitiatedByYou(Object id, Object user_token) { + return 'You have initiated a dispute for order Id: $id. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. Your dispute token is: $user_token.'; + } + + @override + String disputeInitiatedByPeer(Object id, Object user_token) { + return 'Your counterparty has initiated a dispute for order Id: $id. A solver will be assigned soon. Once assigned, I will share their npub with you, and only they will be able to assist you. Your dispute token is: $user_token.'; + } + + @override + String adminTookDisputeAdmin(Object details) { + return '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.'; + } + + @override + String adminTookDisputeUsers(Object admin_npub) { + return '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.'; + } + + @override + String adminCanceledAdmin(Object id) { + return 'You have canceled the order ID: $id.'; + } + + @override + String adminCanceledUsers(Object id) { + return 'Admin has canceled the order ID: $id.'; + } + + @override + String adminSettledAdmin(Object id) { + return 'You have completed the order ID: $id.'; + } + + @override + String adminSettledUsers(Object id) { + return 'Admin has completed the order ID: $id.'; + } + + @override + String paymentFailed(Object payment_attempts, Object payment_retries_interval) { + return 'I couldn’t send the Sats. I will try $payment_attempts more times in $payment_retries_interval minutes. Please ensure your node or wallet is online.'; + } + + @override + String get invoiceUpdated => 'The invoice has been successfully updated!'; + + @override + String get holdInvoicePaymentCanceled => 'The invoice was canceled; your Sats are available in your wallet again.'; + + @override + String cantDo(Object action) { + return 'You are not allowed to $action for this order!'; + } + + @override + String adminAddSolver(Object npub) { + return 'You have successfully added the solver $npub.'; + } + + @override + String get invalidSignature => 'The action cannot be completed because the signature is invalid.'; + + @override + String get invalidTradeIndex => 'The provided trade index is invalid. Please ensure your client is synchronized and try again.'; + + @override + String get invalidAmount => 'The provided amount is invalid. Please verify it and try again.'; + + @override + String get invalidInvoice => 'The provided Lightning invoice is invalid. Please check the invoice details and try again.'; + + @override + String get invalidPaymentRequest => 'The payment request is invalid or cannot be processed.'; + + @override + String get invalidPeer => 'You are not authorized to perform this action.'; + + @override + String get invalidRating => 'The rating value is invalid or out of range.'; + + @override + String get invalidTextMessage => 'The text message is invalid or contains prohibited content.'; + + @override + String get invalidOrderKind => 'The order kind is invalid.'; + + @override + String get invalidOrderStatus => 'The action cannot be completed due to the current order status.'; + + @override + String get invalidPubkey => 'The action cannot be completed because the public key is invalid.'; + + @override + String get invalidParameters => 'The action cannot be completed due to invalid parameters. Please review the provided values and try again.'; + + @override + String get orderAlreadyCanceled => 'The action cannot be completed because the order has already been canceled.'; + + @override + String get cantCreateUser => 'The action cannot be completed because the user could not be created.'; + + @override + String get isNotYourOrder => 'This order does not belong to you.'; + + @override + String notAllowedByStatus(Object id, Object order_status) { + return 'The action cannot be completed because order Id $id status is $order_status.'; + } + + @override + String outOfRangeFiatAmount(Object max_amount, Object min_amount) { + return 'The requested fiat amount is outside the acceptable range ($min_amount–$max_amount).'; + } + + @override + String outOfRangeSatsAmount(Object max_order_amount, Object min_order_amount) { + return '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.'; + } + + @override + String get isNotYourDispute => 'This dispute is not assigned to you.'; + + @override + String get disputeCreationError => 'A dispute cannot be created for this order.'; + + @override + String get notFound => 'The requested dispute could not be found.'; + + @override + String get invalidDisputeStatus => 'The dispute status is invalid.'; + + @override + String get invalidAction => 'The requested action is invalid.'; + + @override + String get pendingOrderExists => 'A pending order already exists.'; +} diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index ebc5283a..71869852 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -74,8 +74,7 @@ class MostroService { } Session? getSessionByOrderId(String orderId) { - final session = _sessionManager.getSessionByOrderId(orderId); - return session; + return _sessionManager.getSessionByOrderId(orderId); } Future takeSellOrder( @@ -85,27 +84,38 @@ class MostroService { : amount != null ? Amount(amount: amount) : null; - final order = - MostroMessage(action: Action.takeSell, id: orderId, payload: payload); - final session = await publishOrder(order); - return session; + return await publishOrder( + MostroMessage(action: Action.takeSell, id: orderId, payload: payload)); } Future sendInvoice(String orderId, String invoice, int? amount) async { final payload = PaymentRequest(order: null, lnInvoice: invoice, amount: amount); - final order = - MostroMessage(action: Action.addInvoice, id: orderId, payload: payload); - await publishOrder(order); + await publishOrder(MostroMessage( + action: Action.addInvoice, id: orderId, payload: payload)); } Future takeBuyOrder(String orderId, int? amount) async { final amt = amount != null ? Amount(amount: amount) : null; - final order = - MostroMessage(action: Action.takeBuy, id: orderId, payload: amt); - final session = await publishOrder(order); - return session; + return await publishOrder( + MostroMessage(action: Action.takeBuy, id: orderId, payload: amt)); + } + + Future cancelOrder(String orderId) async { + await publishOrder(MostroMessage(action: Action.cancel, id: orderId)); + } + + Future sendFiatSent(String orderId) async { + await publishOrder(MostroMessage(action: Action.fiatSent, id: orderId)); + } + + Future releaseOrder(String orderId) async { + await publishOrder(MostroMessage(action: Action.release, id: orderId)); + } + + Future disputeOrder(String orderId) async { + await publishOrder(MostroMessage(action: Action.dispute, id: orderId)); } Future publishOrder(MostroMessage order) async { @@ -128,21 +138,6 @@ class MostroService { return session; } - Future cancelOrder(String orderId) async { - final order = MostroMessage(action: Action.cancel, id: orderId); - await publishOrder(order); - } - - Future sendFiatSent(String orderId) async { - final order = MostroMessage(action: Action.fiatSent, id: orderId); - await publishOrder(order); - } - - Future releaseOrder(String orderId) async { - final order = MostroMessage(action: Action.release, id: orderId); - await publishOrder(order); - } - Future createNIP59Event( String content, String recipientPubKey, Session session) async { final keySet = session.fullPrivacy ? session.tradeKey : session.masterKey; diff --git a/lib/shared/widgets/notification_listener_widget.dart b/lib/shared/widgets/notification_listener_widget.dart index 6a8ef684..751187c2 100644 --- a/lib/shared/widgets/notification_listener_widget.dart +++ b/lib/shared/widgets/notification_listener_widget.dart @@ -18,7 +18,7 @@ class NotificationListenerWidget extends ConsumerWidget { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(S - .of(context) + .of(context)! .actionLabel(next.action!, placeholders: next.placeholders))), ); // Clear notification after showing to prevent repetition @@ -29,7 +29,7 @@ class NotificationListenerWidget extends ConsumerWidget { builder: (context) => AlertDialog( title: Text('Action Required'), content: Text(S - .of(context) + .of(context)! .actionLabel(next.action!, placeholders: next.placeholders)), actions: [ TextButton( diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index 784f0320..08e31e37 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -7,13 +7,13 @@ import 'dart:async' as _i5; import 'package:dart_nostr/dart_nostr.dart' as _i3; import 'package:mockito/mockito.dart' as _i1; -import 'package:mostro_mobile/data/models/enums/action.dart' as _i8; import 'package:mostro_mobile/data/models/mostro_message.dart' as _i6; import 'package:mostro_mobile/data/models/payload.dart' as _i7; import 'package:mostro_mobile/data/models/session.dart' as _i2; import 'package:mostro_mobile/data/repositories/mostro_repository.dart' as _i9; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart' as _i10; +import 'package:mostro_mobile/features/settings/settings.dart' as _i8; import 'package:mostro_mobile/services/mostro_service.dart' as _i4; // ignore_for_file: type=lint @@ -32,12 +32,12 @@ import 'package:mostro_mobile/services/mostro_service.dart' as _i4; class _FakeSession_0 extends _i1.SmartFake implements _i2.Session { _FakeSession_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + : super(parent, parentInvocation); } class _FakeNostrEvent_1 extends _i1.SmartFake implements _i3.NostrEvent { _FakeNostrEvent_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + : super(parent, parentInvocation); } /// A class which mocks [MostroService]. @@ -51,9 +51,10 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { @override _i5.Stream<_i6.MostroMessage<_i7.Payload>> subscribe(_i2.Session? session) => (super.noSuchMethod( - Invocation.method(#subscribe, [session]), - returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ) as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); + Invocation.method(#subscribe, [session]), + returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ) + as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); @override _i2.Session? getSessionByOrderId(String? orderId) => @@ -67,91 +68,83 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { String? lnAddress, ) => (super.noSuchMethod( - Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), - returnValue: _i5.Future<_i2.Session>.value( - _FakeSession_0( - this, Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), - ), - ), - ) as _i5.Future<_i2.Session>); + returnValue: _i5.Future<_i2.Session>.value( + _FakeSession_0( + this, + Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), + ), + ), + ) + as _i5.Future<_i2.Session>); @override _i5.Future sendInvoice(String? orderId, String? invoice, int? amount) => (super.noSuchMethod( - Invocation.method(#sendInvoice, [orderId, invoice, amount]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#sendInvoice, [orderId, invoice, amount]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override _i5.Future<_i2.Session> takeBuyOrder(String? orderId, int? amount) => (super.noSuchMethod( - Invocation.method(#takeBuyOrder, [orderId, amount]), - returnValue: _i5.Future<_i2.Session>.value( - _FakeSession_0( - this, Invocation.method(#takeBuyOrder, [orderId, amount]), - ), - ), - ) as _i5.Future<_i2.Session>); + returnValue: _i5.Future<_i2.Session>.value( + _FakeSession_0( + this, + Invocation.method(#takeBuyOrder, [orderId, amount]), + ), + ), + ) + as _i5.Future<_i2.Session>); @override - _i5.Future<_i2.Session> publishOrder(_i6.MostroMessage<_i7.Payload>? order) => + _i5.Future cancelOrder(String? orderId) => (super.noSuchMethod( - Invocation.method(#publishOrder, [order]), - returnValue: _i5.Future<_i2.Session>.value( - _FakeSession_0(this, Invocation.method(#publishOrder, [order])), - ), - ) as _i5.Future<_i2.Session>); - - @override - _i5.Future cancelOrder(String? orderId) => (super.noSuchMethod( - Invocation.method(#cancelOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#cancelOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future sendFiatSent(String? orderId) => (super.noSuchMethod( - Invocation.method(#sendFiatSent, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future sendFiatSent(String? orderId) => + (super.noSuchMethod( + Invocation.method(#sendFiatSent, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future releaseOrder(String? orderId) => (super.noSuchMethod( - Invocation.method(#releaseOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future releaseOrder(String? orderId) => + (super.noSuchMethod( + Invocation.method(#releaseOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - Map newMessage( - _i8.Action? actionType, - String? orderId, { - Object? payload, - }) => + _i5.Future disputeOrder(String? orderId) => (super.noSuchMethod( - Invocation.method( - #newMessage, - [actionType, orderId], - {#payload: payload}, - ), - returnValue: {}, - ) as Map); + Invocation.method(#disputeOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future sendMessage( - String? orderId, - String? receiverPubkey, - Map? content, - ) => + _i5.Future<_i2.Session> publishOrder(_i6.MostroMessage<_i7.Payload>? order) => (super.noSuchMethod( - Invocation.method(#sendMessage, [orderId, receiverPubkey, content]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#publishOrder, [order]), + returnValue: _i5.Future<_i2.Session>.value( + _FakeSession_0(this, Invocation.method(#publishOrder, [order])), + ), + ) + as _i5.Future<_i2.Session>); @override _i5.Future<_i3.NostrEvent> createNIP59Event( @@ -160,22 +153,29 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { _i2.Session? session, ) => (super.noSuchMethod( - Invocation.method(#createNIP59Event, [ - content, - recipientPubKey, - session, - ]), - returnValue: _i5.Future<_i3.NostrEvent>.value( - _FakeNostrEvent_1( - this, Invocation.method(#createNIP59Event, [ content, recipientPubKey, session, ]), - ), - ), - ) as _i5.Future<_i3.NostrEvent>); + returnValue: _i5.Future<_i3.NostrEvent>.value( + _FakeNostrEvent_1( + this, + Invocation.method(#createNIP59Event, [ + content, + recipientPubKey, + session, + ]), + ), + ), + ) + as _i5.Future<_i3.NostrEvent>); + + @override + void updateSettings(_i8.Settings? settings) => super.noSuchMethod( + Invocation.method(#updateSettings, [settings]), + returnValueForMissingStub: null, + ); } /// A class which mocks [MostroRepository]. @@ -187,26 +187,30 @@ class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { } @override - List<_i6.MostroMessage<_i7.Payload>> get allMessages => (super.noSuchMethod( - Invocation.getter(#allMessages), - returnValue: <_i6.MostroMessage<_i7.Payload>>[], - ) as List<_i6.MostroMessage<_i7.Payload>>); + List<_i6.MostroMessage<_i7.Payload>> get allMessages => + (super.noSuchMethod( + Invocation.getter(#allMessages), + returnValue: <_i6.MostroMessage<_i7.Payload>>[], + ) + as List<_i6.MostroMessage<_i7.Payload>>); @override _i5.Future<_i6.MostroMessage<_i7.Payload>?> getOrderById(String? orderId) => (super.noSuchMethod( - Invocation.method(#getOrderById, [orderId]), - returnValue: _i5.Future<_i6.MostroMessage<_i7.Payload>?>.value(), - ) as _i5.Future<_i6.MostroMessage<_i7.Payload>?>); + Invocation.method(#getOrderById, [orderId]), + returnValue: _i5.Future<_i6.MostroMessage<_i7.Payload>?>.value(), + ) + as _i5.Future<_i6.MostroMessage<_i7.Payload>?>); @override _i5.Stream<_i6.MostroMessage<_i7.Payload>> resubscribeOrder( String? orderId, ) => (super.noSuchMethod( - Invocation.method(#resubscribeOrder, [orderId]), - returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ) as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); + Invocation.method(#resubscribeOrder, [orderId]), + returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ) + as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); @override _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> takeSellOrder( @@ -215,12 +219,13 @@ class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { String? lnAddress, ) => (super.noSuchMethod( - Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ), - ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ), + ) + as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); @override _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> takeBuyOrder( @@ -228,106 +233,150 @@ class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { int? amount, ) => (super.noSuchMethod( - Invocation.method(#takeBuyOrder, [orderId, amount]), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ), - ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + Invocation.method(#takeBuyOrder, [orderId, amount]), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ), + ) + as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); @override _i5.Future sendInvoice(String? orderId, String? invoice, int? amount) => (super.noSuchMethod( - Invocation.method(#sendInvoice, [orderId, invoice, amount]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#sendInvoice, [orderId, invoice, amount]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> publishOrder( _i6.MostroMessage<_i7.Payload>? order, ) => (super.noSuchMethod( - Invocation.method(#publishOrder, [order]), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ), - ) as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + Invocation.method(#publishOrder, [order]), + returnValue: + _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( + _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + ), + ) + as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); @override - _i5.Future cancelOrder(String? orderId) => (super.noSuchMethod( - Invocation.method(#cancelOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future cancelOrder(String? orderId) => + (super.noSuchMethod( + Invocation.method(#cancelOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future saveMessages() => (super.noSuchMethod( - Invocation.method(#saveMessages, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future saveMessages() => + (super.noSuchMethod( + Invocation.method(#saveMessages, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override _i5.Future saveMessage(_i6.MostroMessage<_i7.Payload>? message) => (super.noSuchMethod( - Invocation.method(#saveMessage, [message]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#saveMessage, [message]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future deleteMessage(String? messageId) => (super.noSuchMethod( - Invocation.method(#deleteMessage, [messageId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future deleteMessage(String? messageId) => + (super.noSuchMethod( + Invocation.method(#deleteMessage, [messageId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future loadMessages() => (super.noSuchMethod( - Invocation.method(#loadMessages, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future loadMessages() => + (super.noSuchMethod( + Invocation.method(#loadMessages, []), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); @override _i5.Future addOrder(_i6.MostroMessage<_i7.Payload>? order) => (super.noSuchMethod( - Invocation.method(#addOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#addOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future deleteOrder(String? orderId) => (super.noSuchMethod( - Invocation.method(#deleteOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future deleteOrder(String? orderId) => + (super.noSuchMethod( + Invocation.method(#deleteOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override _i5.Future>> getAllOrders() => (super.noSuchMethod( - Invocation.method(#getAllOrders, []), - returnValue: _i5.Future>>.value( - <_i6.MostroMessage<_i7.Payload>>[], - ), - ) as _i5.Future>>); + Invocation.method(#getAllOrders, []), + returnValue: _i5.Future>>.value( + <_i6.MostroMessage<_i7.Payload>>[], + ), + ) + as _i5.Future>>); @override _i5.Future updateOrder(_i6.MostroMessage<_i7.Payload>? order) => (super.noSuchMethod( - Invocation.method(#updateOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + Invocation.method(#updateOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future sendFiatSent(String? orderId) => + (super.noSuchMethod( + Invocation.method(#sendFiatSent, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future releaseOrder(String? orderId) => + (super.noSuchMethod( + Invocation.method(#releaseOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + + @override + _i5.Future disputeOrder(String? orderId) => + (super.noSuchMethod( + Invocation.method(#disputeOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); } /// A class which mocks [OpenOrdersRepository]. @@ -340,62 +389,67 @@ class MockOpenOrdersRepository extends _i1.Mock } @override - _i5.Stream> get eventsStream => (super.noSuchMethod( - Invocation.getter(#eventsStream), - returnValue: _i5.Stream>.empty(), - ) as _i5.Stream>); - - @override - List<_i3.NostrEvent> get currentEvents => (super.noSuchMethod( - Invocation.getter(#currentEvents), - returnValue: <_i3.NostrEvent>[], - ) as List<_i3.NostrEvent>); - - @override - void _subscribeToOrders() => super.noSuchMethod( - Invocation.method(#subscribeToOrders, []), - returnValueForMissingStub: null, - ); + _i5.Stream> get eventsStream => + (super.noSuchMethod( + Invocation.getter(#eventsStream), + returnValue: _i5.Stream>.empty(), + ) + as _i5.Stream>); @override void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); @override _i5.Future<_i3.NostrEvent?> getOrderById(String? orderId) => (super.noSuchMethod( - Invocation.method(#getOrderById, [orderId]), - returnValue: _i5.Future<_i3.NostrEvent?>.value(), - ) as _i5.Future<_i3.NostrEvent?>); + Invocation.method(#getOrderById, [orderId]), + returnValue: _i5.Future<_i3.NostrEvent?>.value(), + ) + as _i5.Future<_i3.NostrEvent?>); @override - _i5.Future addOrder(_i3.NostrEvent? order) => (super.noSuchMethod( - Invocation.method(#addOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future addOrder(_i3.NostrEvent? order) => + (super.noSuchMethod( + Invocation.method(#addOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future deleteOrder(String? orderId) => (super.noSuchMethod( - Invocation.method(#deleteOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + _i5.Future deleteOrder(String? orderId) => + (super.noSuchMethod( + Invocation.method(#deleteOrder, [orderId]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future> getAllOrders() => (super.noSuchMethod( - Invocation.method(#getAllOrders, []), - returnValue: _i5.Future>.value( - <_i3.NostrEvent>[], - ), - ) as _i5.Future>); + _i5.Future> getAllOrders() => + (super.noSuchMethod( + Invocation.method(#getAllOrders, []), + returnValue: _i5.Future>.value( + <_i3.NostrEvent>[], + ), + ) + as _i5.Future>); + + @override + _i5.Future updateOrder(_i3.NostrEvent? order) => + (super.noSuchMethod( + Invocation.method(#updateOrder, [order]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); @override - _i5.Future updateOrder(_i3.NostrEvent? order) => (super.noSuchMethod( - Invocation.method(#updateOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); + void updateSettings(_i8.Settings? settings) => super.noSuchMethod( + Invocation.method(#updateSettings, [settings]), + returnValueForMissingStub: null, + ); } diff --git a/test/services/mostro_service_test.dart b/test/services/mostro_service_test.dart index 8b6f95e8..560b8a64 100644 --- a/test/services/mostro_service_test.dart +++ b/test/services/mostro_service_test.dart @@ -30,6 +30,7 @@ void main() { setUp(() { mockNostrService = MockNostrService(); mockSessionManager = MockSessionManager(); + mockSessionNotifier = MockSessionNotifier(); mostroService = MostroService(mockNostrService, mockSessionNotifier, Settings(relays: [], fullPrivacyMode: true, mostroPublicKey: 'xxx')); keyDerivator = KeyDerivator("m/44'/1237'/38383'/0"); diff --git a/test/services/mostro_service_test.mocks.dart b/test/services/mostro_service_test.mocks.dart index 946d82c3..f04e4f94 100644 --- a/test/services/mostro_service_test.mocks.dart +++ b/test/services/mostro_service_test.mocks.dart @@ -3,15 +3,19 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; +import 'dart:async' as _i6; -import 'package:dart_nostr/dart_nostr.dart' as _i2; -import 'package:dart_nostr/nostr/model/relay_informations.dart' as _i6; +import 'package:dart_nostr/dart_nostr.dart' as _i3; +import 'package:dart_nostr/nostr/model/relay_informations.dart' as _i7; +import 'package:flutter_riverpod/flutter_riverpod.dart' as _i11; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i7; -import 'package:mostro_mobile/data/models/session.dart' as _i3; -import 'package:mostro_mobile/data/repositories/session_manager.dart' as _i8; -import 'package:mostro_mobile/services/nostr_service.dart' as _i4; +import 'package:mockito/src/dummies.dart' as _i8; +import 'package:mostro_mobile/data/models/session.dart' as _i4; +import 'package:mostro_mobile/data/repositories/session_manager.dart' as _i9; +import 'package:mostro_mobile/features/settings/settings.dart' as _i2; +import 'package:mostro_mobile/services/nostr_service.dart' as _i5; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart' as _i10; +import 'package:state_notifier/state_notifier.dart' as _i12; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -27,106 +31,134 @@ import 'package:mostro_mobile/services/nostr_service.dart' as _i4; // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class -class _FakeNostrKeyPairs_0 extends _i1.SmartFake implements _i2.NostrKeyPairs { - _FakeNostrKeyPairs_0(Object parent, Invocation parentInvocation) +class _FakeSettings_0 extends _i1.SmartFake implements _i2.Settings { + _FakeSettings_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeNostrEvent_1 extends _i1.SmartFake implements _i2.NostrEvent { - _FakeNostrEvent_1(Object parent, Invocation parentInvocation) +class _FakeNostrKeyPairs_1 extends _i1.SmartFake implements _i3.NostrKeyPairs { + _FakeNostrKeyPairs_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeSession_2 extends _i1.SmartFake implements _i3.Session { - _FakeSession_2(Object parent, Invocation parentInvocation) +class _FakeNostrEvent_2 extends _i1.SmartFake implements _i3.NostrEvent { + _FakeNostrEvent_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeSession_3 extends _i1.SmartFake implements _i4.Session { + _FakeSession_3(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } /// A class which mocks [NostrService]. /// /// See the documentation for Mockito's code generation for more information. -class MockNostrService extends _i1.Mock implements _i4.NostrService { +class MockNostrService extends _i1.Mock implements _i5.NostrService { MockNostrService() { _i1.throwOnMissingStub(this); } + @override + _i2.Settings get settings => + (super.noSuchMethod( + Invocation.getter(#settings), + returnValue: _FakeSettings_0(this, Invocation.getter(#settings)), + ) + as _i2.Settings); + + @override + set settings(_i2.Settings? _settings) => super.noSuchMethod( + Invocation.setter(#settings, _settings), + returnValueForMissingStub: null, + ); + @override bool get isInitialized => (super.noSuchMethod(Invocation.getter(#isInitialized), returnValue: false) as bool); @override - _i5.Future init() => + _i6.Future init() => (super.noSuchMethod( Invocation.method(#init, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i5.Future); + as _i6.Future); @override - _i5.Future<_i6.RelayInformations?> getRelayInfo(String? relayUrl) => + _i6.Future updateSettings(_i2.Settings? newSettings) => + (super.noSuchMethod( + Invocation.method(#updateSettings, [newSettings]), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + _i6.Future<_i7.RelayInformations?> getRelayInfo(String? relayUrl) => (super.noSuchMethod( Invocation.method(#getRelayInfo, [relayUrl]), - returnValue: _i5.Future<_i6.RelayInformations?>.value(), + returnValue: _i6.Future<_i7.RelayInformations?>.value(), ) - as _i5.Future<_i6.RelayInformations?>); + as _i6.Future<_i7.RelayInformations?>); @override - _i5.Future publishEvent(_i2.NostrEvent? event) => + _i6.Future publishEvent(_i3.NostrEvent? event) => (super.noSuchMethod( Invocation.method(#publishEvent, [event]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i5.Future); + as _i6.Future); @override - _i5.Stream<_i2.NostrEvent> subscribeToEvents(_i2.NostrFilter? filter) => + _i6.Stream<_i3.NostrEvent> subscribeToEvents(_i3.NostrFilter? filter) => (super.noSuchMethod( Invocation.method(#subscribeToEvents, [filter]), - returnValue: _i5.Stream<_i2.NostrEvent>.empty(), + returnValue: _i6.Stream<_i3.NostrEvent>.empty(), ) - as _i5.Stream<_i2.NostrEvent>); + as _i6.Stream<_i3.NostrEvent>); @override - _i5.Future disconnectFromRelays() => + _i6.Future disconnectFromRelays() => (super.noSuchMethod( Invocation.method(#disconnectFromRelays, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i5.Future); + as _i6.Future); @override - _i5.Future<_i2.NostrKeyPairs> generateKeyPair() => + _i6.Future<_i3.NostrKeyPairs> generateKeyPair() => (super.noSuchMethod( Invocation.method(#generateKeyPair, []), - returnValue: _i5.Future<_i2.NostrKeyPairs>.value( - _FakeNostrKeyPairs_0( + returnValue: _i6.Future<_i3.NostrKeyPairs>.value( + _FakeNostrKeyPairs_1( this, Invocation.method(#generateKeyPair, []), ), ), ) - as _i5.Future<_i2.NostrKeyPairs>); + as _i6.Future<_i3.NostrKeyPairs>); @override - _i2.NostrKeyPairs generateKeyPairFromPrivateKey(String? privateKey) => + _i3.NostrKeyPairs generateKeyPairFromPrivateKey(String? privateKey) => (super.noSuchMethod( Invocation.method(#generateKeyPairFromPrivateKey, [privateKey]), - returnValue: _FakeNostrKeyPairs_0( + returnValue: _FakeNostrKeyPairs_1( this, Invocation.method(#generateKeyPairFromPrivateKey, [privateKey]), ), ) - as _i2.NostrKeyPairs); + as _i3.NostrKeyPairs); @override String getMostroPubKey() => (super.noSuchMethod( Invocation.method(#getMostroPubKey, []), - returnValue: _i7.dummyValue( + returnValue: _i8.dummyValue( this, Invocation.method(#getMostroPubKey, []), ), @@ -134,7 +166,7 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { as String); @override - _i5.Future<_i2.NostrEvent> createNIP59Event( + _i6.Future<_i3.NostrEvent> createNIP59Event( String? content, String? recipientPubKey, String? senderPrivateKey, @@ -145,8 +177,8 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { recipientPubKey, senderPrivateKey, ]), - returnValue: _i5.Future<_i2.NostrEvent>.value( - _FakeNostrEvent_1( + returnValue: _i6.Future<_i3.NostrEvent>.value( + _FakeNostrEvent_2( this, Invocation.method(#createNIP59Event, [ content, @@ -156,27 +188,27 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { ), ), ) - as _i5.Future<_i2.NostrEvent>); + as _i6.Future<_i3.NostrEvent>); @override - _i5.Future<_i2.NostrEvent> decryptNIP59Event( - _i2.NostrEvent? event, + _i6.Future<_i3.NostrEvent> decryptNIP59Event( + _i3.NostrEvent? event, String? privateKey, ) => (super.noSuchMethod( Invocation.method(#decryptNIP59Event, [event, privateKey]), - returnValue: _i5.Future<_i2.NostrEvent>.value( - _FakeNostrEvent_1( + returnValue: _i6.Future<_i3.NostrEvent>.value( + _FakeNostrEvent_2( this, Invocation.method(#decryptNIP59Event, [event, privateKey]), ), ), ) - as _i5.Future<_i2.NostrEvent>); + as _i6.Future<_i3.NostrEvent>); @override - _i5.Future createRumor( - _i2.NostrKeyPairs? senderKeyPair, + _i6.Future createRumor( + _i3.NostrKeyPairs? senderKeyPair, String? wrapperKey, String? recipientPubKey, String? content, @@ -188,8 +220,8 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { recipientPubKey, content, ]), - returnValue: _i5.Future.value( - _i7.dummyValue( + returnValue: _i6.Future.value( + _i8.dummyValue( this, Invocation.method(#createRumor, [ senderKeyPair, @@ -200,11 +232,11 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { ), ), ) - as _i5.Future); + as _i6.Future); @override - _i5.Future createSeal( - _i2.NostrKeyPairs? senderKeyPair, + _i6.Future createSeal( + _i3.NostrKeyPairs? senderKeyPair, String? wrapperKey, String? recipientPubKey, String? encryptedContent, @@ -216,8 +248,8 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { recipientPubKey, encryptedContent, ]), - returnValue: _i5.Future.value( - _i7.dummyValue( + returnValue: _i6.Future.value( + _i8.dummyValue( this, Invocation.method(#createSeal, [ senderKeyPair, @@ -228,11 +260,11 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { ), ), ) - as _i5.Future); + as _i6.Future); @override - _i5.Future<_i2.NostrEvent> createWrap( - _i2.NostrKeyPairs? wrapperKeyPair, + _i6.Future<_i3.NostrEvent> createWrap( + _i3.NostrKeyPairs? wrapperKeyPair, String? sealedContent, String? recipientPubKey, ) => @@ -242,8 +274,8 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { sealedContent, recipientPubKey, ]), - returnValue: _i5.Future<_i2.NostrEvent>.value( - _FakeNostrEvent_1( + returnValue: _i6.Future<_i3.NostrEvent>.value( + _FakeNostrEvent_2( this, Invocation.method(#createWrap, [ wrapperKeyPair, @@ -253,13 +285,13 @@ class MockNostrService extends _i1.Mock implements _i4.NostrService { ), ), ) - as _i5.Future<_i2.NostrEvent>); + as _i6.Future<_i3.NostrEvent>); } /// A class which mocks [SessionManager]. /// /// See the documentation for Mockito's code generation for more information. -class MockSessionManager extends _i1.Mock implements _i8.SessionManager { +class MockSessionManager extends _i1.Mock implements _i9.SessionManager { MockSessionManager() { _i1.throwOnMissingStub(this); } @@ -273,78 +305,236 @@ class MockSessionManager extends _i1.Mock implements _i8.SessionManager { as int); @override - List<_i3.Session> get sessions => + List<_i4.Session> get sessions => (super.noSuchMethod( Invocation.getter(#sessions), - returnValue: <_i3.Session>[], + returnValue: <_i4.Session>[], ) - as List<_i3.Session>); + as List<_i4.Session>); @override - _i5.Future init() => + _i6.Future init() => (super.noSuchMethod( Invocation.method(#init, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i5.Future); + as _i6.Future); @override - _i5.Future<_i3.Session> newSession({String? orderId}) => + _i6.Future reset() => + (super.noSuchMethod( + Invocation.method(#reset, []), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + void updateSettings(_i2.Settings? settings) => super.noSuchMethod( + Invocation.method(#updateSettings, [settings]), + returnValueForMissingStub: null, + ); + + @override + _i6.Future<_i4.Session> newSession({String? orderId}) => (super.noSuchMethod( Invocation.method(#newSession, [], {#orderId: orderId}), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_2( + returnValue: _i6.Future<_i4.Session>.value( + _FakeSession_3( this, Invocation.method(#newSession, [], {#orderId: orderId}), ), ), ) - as _i5.Future<_i3.Session>); + as _i6.Future<_i4.Session>); @override - _i5.Future saveSession(_i3.Session? session) => + _i6.Future saveSession(_i4.Session? session) => (super.noSuchMethod( Invocation.method(#saveSession, [session]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i5.Future); + as _i6.Future); @override - _i3.Session? getSessionByOrderId(String? orderId) => + _i4.Session? getSessionByOrderId(String? orderId) => (super.noSuchMethod(Invocation.method(#getSessionByOrderId, [orderId])) - as _i3.Session?); + as _i4.Session?); @override - _i5.Future<_i3.Session?> loadSession(int? keyIndex) => + _i6.Future<_i4.Session?> loadSession(int? keyIndex) => (super.noSuchMethod( Invocation.method(#loadSession, [keyIndex]), - returnValue: _i5.Future<_i3.Session?>.value(), + returnValue: _i6.Future<_i4.Session?>.value(), ) - as _i5.Future<_i3.Session?>); + as _i6.Future<_i4.Session?>); @override - _i5.Future deleteSession(int? sessionId) => + _i6.Future deleteSession(int? sessionId) => (super.noSuchMethod( Invocation.method(#deleteSession, [sessionId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), ) - as _i5.Future); + as _i6.Future); @override - _i5.Future clearExpiredSessions() => + _i6.Future clearExpiredSessions() => (super.noSuchMethod( Invocation.method(#clearExpiredSessions, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + void dispose() => super.noSuchMethod( + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); +} + +/// A class which mocks [SessionNotifier]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSessionNotifier extends _i1.Mock implements _i10.SessionNotifier { + MockSessionNotifier() { + _i1.throwOnMissingStub(this); + } + + @override + set onError(_i11.ErrorListener? _onError) => super.noSuchMethod( + Invocation.setter(#onError, _onError), + returnValueForMissingStub: null, + ); + + @override + bool get mounted => + (super.noSuchMethod(Invocation.getter(#mounted), returnValue: false) + as bool); + + @override + _i6.Stream> get stream => + (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i6.Stream>.empty(), + ) + as _i6.Stream>); + + @override + List<_i4.Session> get state => + (super.noSuchMethod( + Invocation.getter(#state), + returnValue: <_i4.Session>[], + ) + as List<_i4.Session>); + + @override + set state(List<_i4.Session>? value) => super.noSuchMethod( + Invocation.setter(#state, value), + returnValueForMissingStub: null, + ); + + @override + List<_i4.Session> get debugState => + (super.noSuchMethod( + Invocation.getter(#debugState), + returnValue: <_i4.Session>[], ) - as _i5.Future); + as List<_i4.Session>); + + @override + bool get hasListeners => + (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) + as bool); + + @override + _i6.Future<_i4.Session> newSession({String? orderId}) => + (super.noSuchMethod( + Invocation.method(#newSession, [], {#orderId: orderId}), + returnValue: _i6.Future<_i4.Session>.value( + _FakeSession_3( + this, + Invocation.method(#newSession, [], {#orderId: orderId}), + ), + ), + ) + as _i6.Future<_i4.Session>); + + @override + _i6.Future reset() => + (super.noSuchMethod( + Invocation.method(#reset, []), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + void refresh() => super.noSuchMethod( + Invocation.method(#refresh, []), + returnValueForMissingStub: null, + ); + + @override + _i6.Future saveSession(_i4.Session? session) => + (super.noSuchMethod( + Invocation.method(#saveSession, [session]), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + _i6.Future deleteSession(int? sessionId) => + (super.noSuchMethod( + Invocation.method(#deleteSession, [sessionId]), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + + @override + _i4.Session? getSessionByOrderId(String? orderId) => + (super.noSuchMethod(Invocation.method(#getSessionByOrderId, [orderId])) + as _i4.Session?); + + @override + _i6.Future<_i4.Session?> loadSession(int? keyIndex) => + (super.noSuchMethod( + Invocation.method(#loadSession, [keyIndex]), + returnValue: _i6.Future<_i4.Session?>.value(), + ) + as _i6.Future<_i4.Session?>); @override void dispose() => super.noSuchMethod( Invocation.method(#dispose, []), returnValueForMissingStub: null, ); + + @override + bool updateShouldNotify(List<_i4.Session>? old, List<_i4.Session>? current) => + (super.noSuchMethod( + Invocation.method(#updateShouldNotify, [old, current]), + returnValue: false, + ) + as bool); + + @override + _i11.RemoveListener addListener( + _i12.Listener>? listener, { + bool? fireImmediately = true, + }) => + (super.noSuchMethod( + Invocation.method( + #addListener, + [listener], + {#fireImmediately: fireImmediately}, + ), + returnValue: () {}, + ) + as _i11.RemoveListener); } From 7b6800ac4ff8870bbcde34c9e81c5f0bb931e4d6 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 19 Mar 2025 20:05:18 +1000 Subject: [PATCH 078/149] Implementation of Rate Counterpart --- lib/core/app_routes.dart | 7 +++ lib/data/models/rating_user.dart | 21 ++++++++ lib/data/repositories/mostro_repository.dart | 4 ++ .../notfiers/abstract_order_notifier.dart | 18 +++++-- .../order/notfiers/order_notifier.dart | 4 ++ .../order/screens/take_order_screen.dart | 42 ++++++--------- .../rate/rate_counterpart_screen.dart | 29 +++++++--- .../trades/screens/trade_detail_screen.dart | 54 ++++++++----------- .../widgets/mostro_message_detail_widget.dart | 3 +- lib/generated/action_localizations.dart | 4 +- lib/services/mostro_service.dart | 8 +++ .../providers/order_repository_provider.dart | 15 ++++-- 12 files changed, 132 insertions(+), 77 deletions(-) create mode 100644 lib/data/models/rating_user.dart diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index ab6f323a..9b4c915d 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -8,6 +8,7 @@ import 'package:mostro_mobile/features/messages/screens/chat_rooms_list.dart'; import 'package:mostro_mobile/features/home/screens/home_screen.dart'; import 'package:mostro_mobile/features/key_manager/key_management_screen.dart'; import 'package:mostro_mobile/features/mostro/mostro_screen.dart'; +import 'package:mostro_mobile/features/rate/rate_counterpart_screen.dart'; import 'package:mostro_mobile/features/settings/about_screen.dart'; import 'package:mostro_mobile/features/settings/settings_screen.dart'; import 'package:mostro_mobile/features/trades/screens/trade_detail_screen.dart'; @@ -82,6 +83,12 @@ final goRouter = GoRouter( path: '/add_order', builder: (context, state) => AddOrderScreen(), ), + GoRoute( + path: '/rate_user/:orderId', + builder: (context, state) => RateCounterpartScreen( + orderId: state.pathParameters['orderId']!, + ), + ), GoRoute( path: '/take_sell/:orderId', builder: (context, state) => TakeOrderScreen( diff --git a/lib/data/models/rating_user.dart b/lib/data/models/rating_user.dart new file mode 100644 index 00000000..15e93711 --- /dev/null +++ b/lib/data/models/rating_user.dart @@ -0,0 +1,21 @@ +import 'package:mostro_mobile/data/models/payload.dart'; + +class RatingUser implements Payload { + final int userRating; + + RatingUser({required this.userRating}); + + @override + Map toJson() { + return { + type: userRating, + }; + } + + factory RatingUser.fromJson(dynamic json) { + return RatingUser(userRating: json as int); + } + + @override + String get type => 'rating_user'; +} diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart index 8cbde891..2905c736 100644 --- a/lib/data/repositories/mostro_repository.dart +++ b/lib/data/repositories/mostro_repository.dart @@ -141,4 +141,8 @@ class MostroRepository implements OrderRepository { Future disputeOrder(String orderId) async { await _mostroService.disputeOrder(orderId); } + + Future submitRating(String orderId, int rating) async { + await _mostroService.submitRating(orderId, rating); + } } diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index e81ef5f6..0e6124f6 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -3,10 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; +import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/enums/cant_do_reason.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/peer.dart'; import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; @@ -94,7 +96,6 @@ class AbstractOrderNotifier extends StateNotifier { 'fiat_amount': order?.fiatAmount, 'payment_method': order?.paymentMethod, }); - navProvider.go('/'); break; case Action.canceled: navProvider.go('/'); @@ -109,9 +110,13 @@ class AbstractOrderNotifier extends StateNotifier { 'fiat_amount': order?.fiatAmount, 'payment_method': order?.paymentMethod, }); - navProvider.go('/'); break; case Action.fiatSentOk: + final peer = state.getPayload(); + notifProvider.showInformation(state.action, values: { + 'buyer_npub': peer?.publicKey ?? '{buyer_npub}', + }); + break; case Action.holdInvoicePaymentSettled: case Action.rate: case Action.rateReceived: @@ -119,7 +124,6 @@ class AbstractOrderNotifier extends StateNotifier { notifProvider.showInformation(state.action, values: { 'id': state.id, }); - navProvider.go('/'); break; case Action.disputeInitiatedByYou: case Action.adminSettled: @@ -135,7 +139,13 @@ class AbstractOrderNotifier extends StateNotifier { notifProvider.showInformation(state.action, values: { 'seller_npub': '', }); - + case Action.disputeInitiatedByPeer: + final dispute = state.getPayload()!; + notifProvider.showInformation(state.action, values: { + 'id': state.id!, + 'user_token': dispute.disputeId, + }); + break; default: notifProvider.showInformation(state.action, values: {}); break; diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index b06e1b18..ad379180 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -63,4 +63,8 @@ class OrderNotifier extends AbstractOrderNotifier { await orderRepository.disputeOrder(orderId); } + Future submitRating(int rating) async { + await orderRepository.submitRating(orderId, rating); + } + } diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 223c058c..224cea07 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -24,36 +24,26 @@ class TakeOrderScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final orderAsyncValue = ref.watch(eventProvider(orderId)); + final order = ref.watch(eventProvider(orderId)); return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar(title: 'ORDER DETAILS'), - body: orderAsyncValue.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('Error: $error')), - data: (order) { - if (order == null) { - return Center(child: Text('Order $orderId not found')); - } - // Build the main UI with the order - return SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - const SizedBox(height: 16), - _buildSellerAmount(ref, order), - const SizedBox(height: 16), - _buildOrderId(context), - const SizedBox(height: 24), - _buildCountDownTime(order.expirationDate), - const SizedBox(height: 36), - // Pass the full order to the action buttons widget. - _buildActionButtons(context, ref, order), - ], - ), - ); - }, + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + const SizedBox(height: 16), + _buildSellerAmount(ref, order!), + const SizedBox(height: 16), + _buildOrderId(context), + const SizedBox(height: 24), + _buildCountDownTime(order.expirationDate), + const SizedBox(height: 36), + // Pass the full order to the action buttons widget. + _buildActionButtons(context, ref, order), + ], + ), ), ); } diff --git a/lib/features/rate/rate_counterpart_screen.dart b/lib/features/rate/rate_counterpart_screen.dart index 2218f5ec..a5673628 100644 --- a/lib/features/rate/rate_counterpart_screen.dart +++ b/lib/features/rate/rate_counterpart_screen.dart @@ -1,26 +1,39 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; import 'star_rating.dart'; -class RateCounterpartScreen extends StatefulWidget { - const RateCounterpartScreen({super.key}); +/// Screen that allows users to rate their counterpart after completing an order. +/// Takes an [orderId] parameter to identify which order the rating is for. +class RateCounterpartScreen extends ConsumerStatefulWidget { + final String orderId; + + const RateCounterpartScreen({super.key, required this.orderId}); @override - State createState() => _RateCounterpartScreenState(); + ConsumerState createState() => + _RateCounterpartScreenState(); } -class _RateCounterpartScreenState extends State { +class _RateCounterpartScreenState extends ConsumerState { int _rating = 0; final _logger = Logger(); - void _submitRating() { + Future _submitRating() async { _logger.i('Rating submitted: $_rating'); + final orderNotifer = + ref.watch(orderNotifierProvider(widget.orderId).notifier); + + await orderNotifer.submitRating(_rating); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Rating submitted!')), + SnackBar(content: Text(S.of(context)!.rateReceived)), ); + context.pop(); } @@ -44,8 +57,8 @@ class _RateCounterpartScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( - 'How would you rate your counterpart?', + Text( + S.of(context)!.rate, style: TextStyle(color: AppTheme.cream1, fontSize: 20), textAlign: TextAlign.center, ), diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index e2185e5e..72724b03 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -24,40 +24,30 @@ class TradeDetailScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final orderAsyncValue = ref.watch(eventProvider(orderId)); + final order = ref.watch(eventProvider(orderId)); return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar(title: 'ORDER DETAILS'), - body: orderAsyncValue.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('Error: $error')), - data: (order) { - if (order == null) { - return Center(child: Text('Order $orderId not found')); - } - // Build the main UI with the order - return SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - const SizedBox(height: 16), - _buildSellerAmount(ref, order), - const SizedBox(height: 16), - _buildOrderId(context), - const SizedBox(height: 16), - MostroMessageDetail(order: order), - const SizedBox(height: 24), - _buildCountDownTime(order.expirationDate), - const SizedBox(height: 36), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: _buildActionButtons(context, ref, order), - ), - ], + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + const SizedBox(height: 16), + _buildSellerAmount(ref, order!), + const SizedBox(height: 16), + _buildOrderId(context), + const SizedBox(height: 16), + MostroMessageDetail(order: order), + const SizedBox(height: 24), + _buildCountDownTime(order.expirationDate), + const SizedBox(height: 36), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: _buildActionButtons(context, ref, order), ), - ); - }, + ], + ), ), ); } @@ -206,7 +196,7 @@ class TradeDetailScreen extends ConsumerWidget { Widget _buildRateButton(BuildContext context) { return OutlinedButton( onPressed: () { - context.go('/rate_user'); + context.push('/rate_user/$orderId'); }, style: AppTheme.theme.outlinedButtonTheme.style, child: const Text('RATE'), @@ -228,7 +218,8 @@ class TradeDetailScreen extends ConsumerWidget { context, ), if (message.action != actions.Action.disputeInitiatedByYou && - message.action != actions.Action.disputeInitiatedByPeer) + message.action != actions.Action.disputeInitiatedByPeer && + message.action != actions.Action.rate) _buildDisputeButton(ref), if (message.action == actions.Action.addInvoice) ElevatedButton( @@ -260,6 +251,7 @@ class TradeDetailScreen extends ConsumerWidget { ), child: const Text('RELEASE SATS'), ), + if (message.action == actions.Action.rate) _buildRateButton(context), ]; case Status.fiatSent: return [ diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index 4e850151..237aae89 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -94,8 +94,7 @@ class MostroMessageDetail extends ConsumerWidget { //actionText = S.of(context)!.fiatSentOkSeller; break; case actions.Action.released: - final payload = mostroMessage.getPayload(); - actionText = S.of(context)!.released(payload!.sellerTradePubkey!); + actionText = S.of(context)!.released('{seller_npub}'); break; case actions.Action.purchaseCompleted: actionText = S.of(context)!.purchaseCompleted; diff --git a/lib/generated/action_localizations.dart b/lib/generated/action_localizations.dart index a83ecb5b..a0ab06a8 100644 --- a/lib/generated/action_localizations.dart +++ b/lib/generated/action_localizations.dart @@ -11,10 +11,10 @@ extension ActionLocalizationX on S { return payInvoice(placeholders['amount'], placeholders['fiat_code'], placeholders['fiat_amount'], placeholders['expiration_seconds']); case Action.fiatSentOk: - if (placeholders['seller_npub']) { + if (placeholders['seller_npub'] != null) { return fiatSentOkBuyer(placeholders['seller_npub']); } else { - return fiatSentOkSeller(placeholders['buyer_npub']); + return fiatSentOkSeller(placeholders['buyer_npub'] ?? '{buyer_npub}'); } case Action.released: return released(placeholders['seller_npub']); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 71869852..62b6f533 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -6,6 +6,7 @@ import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/payment_request.dart'; +import 'package:mostro_mobile/data/models/rating_user.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; @@ -118,6 +119,13 @@ class MostroService { await publishOrder(MostroMessage(action: Action.dispute, id: orderId)); } + Future submitRating(String orderId, int rating) async { + await publishOrder(MostroMessage( + action: Action.rateUser, + id: orderId, + payload: RatingUser(userRating: rating))); + } + Future publishOrder(MostroMessage order) async { final session = (order.id != null) ? _sessionManager.getSessionByOrderId(order.id!) ?? diff --git a/lib/shared/providers/order_repository_provider.dart b/lib/shared/providers/order_repository_provider.dart index 9b2e465f..e772ceb8 100644 --- a/lib/shared/providers/order_repository_provider.dart +++ b/lib/shared/providers/order_repository_provider.dart @@ -1,9 +1,11 @@ +import 'package:collection/collection.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; final orderRepositoryProvider = Provider((ref) { final nostrService = ref.read(nostrServiceProvider); @@ -22,8 +24,13 @@ final orderEventsProvider = StreamProvider>((ref) { return orderRepository.eventsStream; }); -final eventProvider = - FutureProvider.family((ref, orderId) { - final repository = ref.read(orderRepositoryProvider); - return repository.getOrderById(orderId); +final eventProvider = Provider.family((ref, orderId) { + + final allEventsAsync = ref.watch(orderEventsProvider); + final allEvents = allEventsAsync.maybeWhen( + data: (data) => data, + orElse: () => [], + ); + // firstWhereOrNull returns null if no match is found + return allEvents.firstWhereOrNull((evt) => (evt as NostrEvent).orderId == orderId); }); From 84835493a3bd584d2b393d52c6414f7b10c5750d Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 19 Mar 2025 22:58:12 +1000 Subject: [PATCH 079/149] Partial implementation of Increment trade index only on success #35 --- lib/data/repositories/session_manager.dart | 6 +++++- lib/features/key_manager/key_manager.dart | 4 ++++ lib/services/nostr_service.dart | 2 -- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index b47892f2..74de2afb 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -96,8 +96,12 @@ class SessionManager { /// Removes a session from memory and the database. Future deleteSession(int sessionId) async { - _sessions.remove(sessionId); + final session = _sessions.remove(sessionId); await _sessionStorage.deleteSession(sessionId); + final keyIndx = await _keyManager.getCurrentKeyIndex(); + if (keyIndx == session!.keyIndex + 1) { + _keyManager.setCurrentKeyIndex(session.keyIndex); + } } /// Periodically clear out expired sessions. diff --git a/lib/features/key_manager/key_manager.dart b/lib/features/key_manager/key_manager.dart index 5e30d8d9..42b8f89d 100644 --- a/lib/features/key_manager/key_manager.dart +++ b/lib/features/key_manager/key_manager.dart @@ -79,4 +79,8 @@ class KeyManager { Future getCurrentKeyIndex() async { return await _storage.readTradeKeyIndex(); } + + Future setCurrentKeyIndex(int index) async { + await _storage.storeTradeKeyIndex(index); + } } diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 82aff34b..f074294e 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -95,8 +95,6 @@ class NostrService { Future generateKeyPair() async { final keyPair = NostrUtils.generateKeyPair(); - //await AuthUtils.savePrivateKeyAndPin( - // keyPair.private, ''); // Consider adding a password parameter return keyPair; } From f55bc91a76e12ee9906833ed9f2d91f0ab650490 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Wed, 19 Mar 2025 23:43:17 +1000 Subject: [PATCH 080/149] Changed AddLightingForm to back to the Home Screen on back/close --- .../screens/add_lightning_invoice_screen.dart | 3 + lib/features/order/widgets/order_app_bar.dart | 2 +- .../trades/screens/trade_detail_screen.dart | 204 +++++++++++------- 3 files changed, 129 insertions(+), 80 deletions(-) diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 235c8e24..bec80646 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; @@ -49,6 +50,7 @@ class _AddLightningInvoiceScreenState try { await orderNotifier.sendInvoice( widget.orderId, invoice, amount); + context.go('/'); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -64,6 +66,7 @@ class _AddLightningInvoiceScreenState ref.read(orderNotifierProvider(widget.orderId).notifier); try { await orderNotifier.cancelOrder(); + context.go('/'); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/features/order/widgets/order_app_bar.dart b/lib/features/order/widgets/order_app_bar.dart index 6491ed30..457c00f1 100644 --- a/lib/features/order/widgets/order_app_bar.dart +++ b/lib/features/order/widgets/order_app_bar.dart @@ -16,7 +16,7 @@ class OrderAppBar extends StatelessWidget implements PreferredSizeWidget { elevation: 0, leading: IconButton( icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), - onPressed: () => context.pop(), + onPressed: () => context.go('/'), ), title: Text( title, diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 72724b03..e34e38d2 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -20,12 +20,21 @@ import 'package:mostro_mobile/shared/widgets/custom_card.dart'; class TradeDetailScreen extends ConsumerWidget { final String orderId; final TextTheme textTheme = AppTheme.theme.textTheme; + TradeDetailScreen({super.key, required this.orderId}); @override Widget build(BuildContext context, WidgetRef ref) { final order = ref.watch(eventProvider(orderId)); + // Make sure we actually have an order from the provider: + if (order == null) { + return const Scaffold( + backgroundColor: AppTheme.dark1, + body: Center(child: CircularProgressIndicator()), + ); + } + return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar(title: 'ORDER DETAILS'), @@ -34,10 +43,12 @@ class TradeDetailScreen extends ConsumerWidget { child: Column( children: [ const SizedBox(height: 16), - _buildSellerAmount(ref, order!), + // Display basic info about the trade: + _buildSellerAmount(ref, order), const SizedBox(height: 16), _buildOrderId(context), const SizedBox(height: 16), + // Detailed info: includes the last Mostro message action text MostroMessageDetail(order: order), const SizedBox(height: 24), _buildCountDownTime(order.expirationDate), @@ -52,18 +63,26 @@ class TradeDetailScreen extends ConsumerWidget { ); } + /// Builds a card showing the user is "selling/buying X sats for Y fiat" etc. Widget _buildSellerAmount(WidgetRef ref, NostrEvent order) { final selling = order.orderType == OrderType.sell ? 'selling' : 'buying'; + final amountString = '${order.fiatAmount} ${order.currency} ${CurrencyUtils.getFlagFromCurrency(order.currency!)}'; - final satAmount = order.amount == '0' ? '' : ' ${order.amount}'; - final price = order.amount != '0' ? '' : 'at market price'; - final premium = int.parse(order.premium ?? '0'); - final premiumText = premium >= 0 - ? premium == 0 - ? '' - : 'with a +$premium% premium' - : 'with a -$premium% discount'; + + // If `order.amount` is "0", the trade is "at market price" + final isZeroAmount = (order.amount == '0'); + final satText = isZeroAmount ? '' : ' ${order.amount}'; + final priceText = isZeroAmount ? 'at market price' : ''; + + final premium = int.tryParse(order.premium ?? '0') ?? 0; + final premiumText = premium == 0 + ? '' + : (premium > 0) + ? 'with a +$premium% premium' + : 'with a $premium% discount'; + + // Payment method can be multiple, we only display the first for brevity: final method = order.paymentMethods.isNotEmpty ? order.paymentMethods[0] : 'No payment method'; @@ -74,11 +93,11 @@ class TradeDetailScreen extends ConsumerWidget { children: [ Expanded( child: Column( - spacing: 2, + // Using Column with spacing = 2 isn’t standard; using SizedBoxes for spacing is fine. crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'You are $selling$satAmount sats for $amountString $price $premiumText', + 'You are $selling$satText sats for $amountString $priceText $premiumText', style: AppTheme.theme.textTheme.bodyLarge, softWrap: true, ), @@ -89,7 +108,7 @@ class TradeDetailScreen extends ConsumerWidget { ), const SizedBox(height: 16), Text( - 'The payment method is: $method', + 'Payment method: $method', style: textTheme.bodyLarge, ), ], @@ -100,6 +119,7 @@ class TradeDetailScreen extends ConsumerWidget { ); } + /// Show a card with the order ID that can be copied. Widget _buildOrderId(BuildContext context) { return CustomCard( padding: const EdgeInsets.all(2), @@ -108,7 +128,7 @@ class TradeDetailScreen extends ConsumerWidget { children: [ SelectableText( orderId, - style: TextStyle(color: AppTheme.mostroGreen), + style: const TextStyle(color: AppTheme.mostroGreen), ), const SizedBox(width: 16), IconButton( @@ -134,93 +154,63 @@ class TradeDetailScreen extends ConsumerWidget { ); } + /// Build a circular countdown to show how many hours are left until expiration. Widget _buildCountDownTime(DateTime expiration) { - Duration countdown = Duration(hours: 0); + // If expiration has passed, the difference is negative => zero. final now = DateTime.now(); - if (expiration.isAfter(now)) { - countdown = expiration.difference(now); - } + final Duration difference = expiration.isAfter(now) + ? expiration.difference(now) + : const Duration(); + // Display hours left + final hoursLeft = difference.inHours.clamp(0, 9999); return Column( children: [ CircularCountdown( countdownTotal: 24, - countdownRemaining: countdown.inHours, + countdownRemaining: hoursLeft, ), const SizedBox(height: 16), - Text('Time Left: ${countdown.toString().split('.')[0]}'), + Text('Time Left: ${difference.toString().split('.').first}'), ], ); } - Widget _buildCancelButton(WidgetRef ref) { - final orderDetailsNotifier = - ref.read(orderNotifierProvider(orderId).notifier); - - return ElevatedButton( - onPressed: () async { - await orderDetailsNotifier.cancelOrder(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - ), - child: const Text('CANCEL'), - ); - } - - Widget _buildDisputeButton(WidgetRef ref) { - final orderDetailsNotifier = - ref.read(orderNotifierProvider(orderId).notifier); - - return ElevatedButton( - onPressed: () async { - await orderDetailsNotifier.disputeOrder(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - ), - child: const Text('DISPUTE'), - ); - } - - Widget _buildCloseButton(BuildContext context) { - return OutlinedButton( - onPressed: () { - context.pop(); - }, - style: AppTheme.theme.outlinedButtonTheme.style, - child: const Text('CLOSE'), - ); - } - - Widget _buildRateButton(BuildContext context) { - return OutlinedButton( - onPressed: () { - context.push('/rate_user/$orderId'); - }, - style: AppTheme.theme.outlinedButtonTheme.style, - child: const Text('RATE'), - ); - } - + /// Main action button area, switching on `order.status`. + /// Additional checks use `message.action` to refine which button to show. List _buildActionButtons( BuildContext context, WidgetRef ref, NostrEvent order) { final orderDetailsNotifier = ref.read(orderNotifierProvider(orderId).notifier); final message = ref.watch(orderNotifierProvider(orderId)); + // The finite-state-machine approach: decide based on the order.status. + // Then refine if needed using the last action in `message.action`. switch (order.status) { + case Status.pending: + case Status.waitingPayment: + // Usually, a pending or waitingPayment order can be canceled. + // Possibly the user could do more if they’re the buyer vs. seller, + // but for simplicity we show CANCEL only. + return [ + _buildCloseButton(context), + _buildCancelButton(ref), + ]; + case Status.waitingBuyerInvoice: + // Some code lumps in "settledHoldInvoice" and "active" together, + // but let’s keep them separate for clarity: case Status.settledHoldInvoice: case Status.active: return [ - _buildCloseButton( - context, - ), + _buildCloseButton(context), + // If user has not opened a dispute already if (message.action != actions.Action.disputeInitiatedByYou && message.action != actions.Action.disputeInitiatedByPeer && message.action != actions.Action.rate) _buildDisputeButton(ref), + + // If the action is "addInvoice" => maybe show a button to push the invoice screen. if (message.action == actions.Action.addInvoice) ElevatedButton( onPressed: () async { @@ -229,8 +219,10 @@ class TradeDetailScreen extends ConsumerWidget { style: ElevatedButton.styleFrom( backgroundColor: AppTheme.mostroGreen, ), - child: const Text('FIAT SENT'), + child: const Text('ADD INVOICE'), ), + + // If the order is waiting for buyer to confirm fiat was sent if (message.action == actions.Action.holdInvoicePaymentAccepted) ElevatedButton( onPressed: () async { @@ -241,6 +233,8 @@ class TradeDetailScreen extends ConsumerWidget { ), child: const Text('FIAT SENT'), ), + + // If the user is the seller & the buyer is done => show release button if (message.action == actions.Action.buyerTookOrder) ElevatedButton( onPressed: () async { @@ -251,30 +245,33 @@ class TradeDetailScreen extends ConsumerWidget { ), child: const Text('RELEASE SATS'), ), + + // If the user is ready to rate if (message.action == actions.Action.rate) _buildRateButton(context), ]; + case Status.fiatSent: + // Usually the user can open dispute if the other side doesn't confirm, + // or just close the screen and wait. return [ _buildCloseButton(context), _buildDisputeButton(ref), ]; + case Status.cooperativelyCanceled: return [ _buildCloseButton(context), if (message.action == actions.Action.cooperativeCancelInitiatedByPeer) _buildCancelButton(ref), ]; + case Status.success: return [ _buildCloseButton(context), _buildRateButton(context), ]; - case Status.pending: - case Status.waitingPayment: - return [ - _buildCloseButton(context), - _buildCancelButton(ref), - ]; + + // For these statuses, we usually just let the user close the screen. case Status.expired: case Status.dispute: case Status.completedByAdmin: @@ -288,6 +285,55 @@ class TradeDetailScreen extends ConsumerWidget { } } + /// CANCEL + Widget _buildCancelButton(WidgetRef ref) { + final notifier = ref.read(orderNotifierProvider(orderId).notifier); + return ElevatedButton( + onPressed: () async { + await notifier.cancelOrder(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.red1, + ), + child: const Text('CANCEL'), + ); + } + + /// DISPUTE + Widget _buildDisputeButton(WidgetRef ref) { + final notifier = ref.read(orderNotifierProvider(orderId).notifier); + return ElevatedButton( + onPressed: () async { + await notifier.disputeOrder(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.red1, + ), + child: const Text('DISPUTE'), + ); + } + + /// CLOSE + Widget _buildCloseButton(BuildContext context) { + return OutlinedButton( + onPressed: () => context.pop(), + style: AppTheme.theme.outlinedButtonTheme.style, + child: const Text('CLOSE'), + ); + } + + /// RATE + Widget _buildRateButton(BuildContext context) { + return OutlinedButton( + onPressed: () { + context.push('/rate_user/$orderId'); + }, + style: AppTheme.theme.outlinedButtonTheme.style, + child: const Text('RATE'), + ); + } + + /// Format the date time to a user-friendly string with UTC offset String formatDateTime(DateTime dt) { final dateFormatter = DateFormat('EEE MMM dd yyyy HH:mm:ss'); final formattedDate = dateFormatter.format(dt); From 4baa690788cf6a8a6c0bd952cac5a4b847477b4b Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 20 Mar 2025 11:30:56 +1000 Subject: [PATCH 081/149] Revert Increment trade index only on success #35 fix --- lib/data/repositories/session_manager.dart | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index 74de2afb..b47892f2 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -96,12 +96,8 @@ class SessionManager { /// Removes a session from memory and the database. Future deleteSession(int sessionId) async { - final session = _sessions.remove(sessionId); + _sessions.remove(sessionId); await _sessionStorage.deleteSession(sessionId); - final keyIndx = await _keyManager.getCurrentKeyIndex(); - if (keyIndx == session!.keyIndex + 1) { - _keyManager.setCurrentKeyIndex(session.keyIndex); - } } /// Periodically clear out expired sessions. From 9dcfca7a71bd0f2d6eb817fb8a4ccfe663682709 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Thu, 20 Mar 2025 20:39:50 +1000 Subject: [PATCH 082/149] Major refactor of MostroService including deprecation of MostroRepository All MostroRepoistory in memory persistence operations are now embedded in MostroStorage which is now a direct dependency of MostroService --- lib/data/models/nostr_event.dart | 12 +- lib/data/models/session.dart | 40 +++-- lib/data/repositories/mostro_repository.dart | 148 ------------------ lib/data/repositories/mostro_storage.dart | 140 ++++++++++++----- lib/data/repositories/session_manager.dart | 1 - lib/features/mostro/mostro_screen.dart | 3 +- .../notfiers/abstract_order_notifier.dart | 6 +- .../order/notfiers/add_order_notifier.dart | 16 +- .../order/notfiers/order_notifier.dart | 80 ++++++---- .../providers/order_notifier_provider.dart | 4 +- .../trades/screens/trade_detail_screen.dart | 92 ++++++----- lib/services/mostro_service.dart | 148 +++++++++++++++--- lib/services/nostr_service.dart | 49 +++++- lib/shared/providers/app_init_provider.dart | 1 + .../providers/mostro_service_provider.dart | 12 +- test/notifiers/add_order_notifier_test.dart | 6 +- 16 files changed, 425 insertions(+), 333 deletions(-) delete mode 100644 lib/data/repositories/mostro_repository.dart diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index 05d74e60..f3d08c6c 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -2,6 +2,7 @@ import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/range_amount.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/rating.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; import 'package:timeago/timeago.dart' as timeago; import 'package:dart_nostr/dart_nostr.dart'; @@ -46,7 +47,8 @@ extension NostrEventExtensions on NostrEvent { DateTime _getTimeStamp(String timestamp) { final ts = int.parse(timestamp); - return DateTime.fromMillisecondsSinceEpoch(ts * 1000).subtract(Duration(hours: 12)); + return DateTime.fromMillisecondsSinceEpoch(ts * 1000) + .subtract(Duration(hours: 12)); } String _timeAgo(String? ts) { @@ -61,4 +63,12 @@ extension NostrEventExtensions on NostrEvent { return "invalid date"; } } + + Future unWrap(String privateKey) async { + return await NostrUtils.decryptNIP59Event( + this, + privateKey, + ); + } + } diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index dbd08021..58d80271 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -1,4 +1,6 @@ import 'package:dart_nostr/dart_nostr.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/peer.dart'; /// Represents a User session /// @@ -10,32 +12,42 @@ class Session { final bool fullPrivacy; final DateTime startTime; String? orderId; + OrderType? orderType; + Peer? peer; - Session( - {required this.masterKey, - required this.tradeKey, - required this.keyIndex, - required this.startTime, - required this.fullPrivacy, - this.orderId}); + Session({ + required this.masterKey, + required this.tradeKey, + required this.keyIndex, + required this.fullPrivacy, + required this.startTime, + this.orderId, + this.orderType, + this.peer, + }); - // We don't store the keys in the session files Map toJson() => { - 'start_time': startTime.toIso8601String(), - 'event_id': orderId, + 'trade_key': tradeKey.private, 'key_index': keyIndex, 'full_privacy': fullPrivacy, - 'trade_key': tradeKey.private, + 'start_time': startTime.toIso8601String(), + 'order_id': orderId, + 'order_type': orderType?.value, + 'peer': peer?.publicKey, }; factory Session.fromJson(Map json) { return Session( - startTime: DateTime.parse(json['start_time']), masterKey: json['master_key'], - orderId: json['event_id'], - keyIndex: json['key_index'], tradeKey: json['trade_key'], + keyIndex: json['key_index'], fullPrivacy: json['full_privacy'], + startTime: DateTime.parse(json['start_time']), + orderId: json['order_id'], + orderType: json['order_type'] != null + ? OrderType.fromString(json['order_type']) + : null, + peer: json['peer'] != null ? Peer(publicKey: json['peer']) : null, ); } } diff --git a/lib/data/repositories/mostro_repository.dart b/lib/data/repositories/mostro_repository.dart deleted file mode 100644 index 2905c736..00000000 --- a/lib/data/repositories/mostro_repository.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'dart:async'; -import 'package:logger/logger.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; -import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; -import 'package:mostro_mobile/services/mostro_service.dart'; - -class MostroRepository implements OrderRepository { - final MostroService _mostroService; - final MostroStorage _messageStorage; - final Map _messages = {}; - - final Map> _subscriptions = {}; - - MostroRepository(this._mostroService, this._messageStorage); - - final _logger = Logger(); - - @override - Future getOrderById(String orderId) => - Future.value(_messages[orderId]); - - List get allMessages => _messages.values.toList(); - - Stream _subscribe(Session session) { - final stream = _mostroService.subscribe(session); - final subscription = stream.listen( - (msg) async { - // TODO: handle other message payloads - if (msg.payload is Order) { - _messages[msg.id!] = msg; - await saveMessage(msg); - } - }, - onError: (error) { - // Log or handle subscription errors - _logger - .e('Error in subscription for session ${session.keyIndex}: $error'); - }, - cancelOnError: false, - ); - _subscriptions[session.keyIndex] = subscription; - return stream; - } - - Stream resubscribeOrder(String orderId) { - final session = _mostroService.getSessionByOrderId(orderId); - return _subscribe(session!); - } - - Future> takeSellOrder( - String orderId, int? amount, String? lnAddress) async { - final session = - await _mostroService.takeSellOrder(orderId, amount, lnAddress); - return _subscribe(session); - } - - Future> takeBuyOrder( - String orderId, int? amount) async { - final session = await _mostroService.takeBuyOrder(orderId, amount); - return _subscribe(session); - } - - Future sendInvoice(String orderId, String invoice, int? amount) async { - await _mostroService.sendInvoice(orderId, invoice, amount); - } - - Future> publishOrder(MostroMessage order) async { - final session = await _mostroService.publishOrder(order); - return _subscribe(session); - } - - Future cancelOrder(String orderId) async { - await _mostroService.cancelOrder(orderId); - } - - Future saveMessages() async { - //for (var m in _messages.values.toList()) { - //await _messageStorage.addOrder(m); - //} - } - - Future saveMessage(MostroMessage message) async { - //await _messageStorage.addOrder(message); - } - - Future deleteMessage(String messageId) async { - _messages.remove(messageId); - //await _messageStorage.deleteOrder(messageId); - } - - Future loadMessages() async { - final allEntries = await _messageStorage.getAllOrders(); - for (final entry in allEntries) { - _messages[entry.id!] = entry; - } - } - - @override - void dispose() { - for (final subscription in _subscriptions.values) { - subscription.cancel(); - } - _subscriptions.clear(); - } - - @override - Future addOrder(MostroMessage order) { - // TODO: implement addOrder - throw UnimplementedError(); - } - - @override - Future deleteOrder(String orderId) async { - _messages.remove(orderId); - _messageStorage.deleteOrder(orderId); - } - - @override - Future> getAllOrders() { - // TODO: implement getAllOrders - throw UnimplementedError(); - } - - @override - Future updateOrder(MostroMessage order) { - // TODO: implement updateOrder - throw UnimplementedError(); - } - - Future sendFiatSent(String orderId) async { - await _mostroService.sendFiatSent(orderId); - } - - Future releaseOrder(String orderId) async { - await _mostroService.releaseOrder(orderId); - } - - Future disputeOrder(String orderId) async { - await _mostroService.disputeOrder(orderId); - } - - Future submitRating(String orderId, int rating) async { - await _mostroService.submitRating(orderId, rating); - } -} diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 7f9d7deb..12011b6c 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -1,41 +1,75 @@ import 'dart:async'; import 'dart:convert'; import 'package:logger/logger.dart'; -import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; import 'package:sembast/sembast.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; class MostroStorage implements OrderRepository { final Logger _logger = Logger(); final Database _database; + + /// In-memory cache for quick lookups. + final Map _messages = {}; + final StoreRef> _ordersStore = stringMapStoreFactory.store('orders'); MostroStorage(this._database); - /// Save or update a MostroMessage (with an Order payload) in Sembast + Future init() async { + await getAllOrders(); + } + + /// Save or update a MostroMessage @override Future addOrder(MostroMessage message) async { final orderId = message.id; if (orderId == null) { throw ArgumentError('Cannot save an order with a null message.id'); } - final jsonMap = message.toJson(); - await _ordersStore.record(orderId).put(_database, jsonMap); - _logger.i('Order $orderId saved to Sembast'); + + try { + await _database.transaction((txn) async { + final jsonMap = message.toJson(); + await _ordersStore.record(orderId).put(txn, jsonMap); + }); + + // Update in-memory cache + _messages[orderId] = message; + _logger.i('Order $orderId saved to Sembast'); + } catch (e, stack) { + _logger.e('addOrder failed for $orderId', error: e, stackTrace: stack); + rethrow; // Rethrow or handle the error as needed + } + } + + Future addOrders(List orders) async { + for (final order in orders) { + addOrder(order); + } } /// Retrieve an order by ID @override Future getOrderById(String orderId) async { - final record = await _ordersStore.record(orderId).get(_database); - if (record == null) return null; + // First check in-memory cache + if (_messages.containsKey(orderId)) { + return _messages[orderId]; + } + try { + final record = await _ordersStore.record(orderId).get(_database); + if (record == null) { + return null; + } final msg = MostroMessage.deserialized(jsonEncode(record)); + // Update in-memory cache + _messages[orderId] = msg; return msg; - } catch (e) { - _logger.e('Error deserializing order $orderId: $e'); + } catch (e, stack) { + _logger.e('Error deserializing order $orderId', + error: e, stackTrace: stack); return null; } } @@ -43,54 +77,80 @@ class MostroStorage implements OrderRepository { /// Return all orders @override Future> getAllOrders() async { - final records = await _ordersStore.find(_database); - final results = []; - for (final record in records) { - try { - final msg = MostroMessage.deserialized(jsonEncode(record.value)); - results.add(msg); - } catch (e) { - _logger.e('Error deserializing order with key ${record.key}: $e'); + try { + final records = await _ordersStore.find(_database); + final results = []; + for (final record in records) { + try { + final msg = MostroMessage.deserialized(jsonEncode(record.value)); + results.add(msg); + // Update or populate in-memory cache + _messages[record.key] = msg; + } catch (e, stack) { + _logger.e('Error deserializing order with key ${record.key}', + error: e, stackTrace: stack); + } } + return results; + } catch (e, stack) { + _logger.e('getAllOrders failed', error: e, stackTrace: stack); + return []; } - return results; } /// Delete an order from DB @override Future deleteOrder(String orderId) async { - await _ordersStore.record(orderId).delete(_database); + try { + await _database.transaction((txn) async { + await _ordersStore.record(orderId).delete(txn); + }); + _messages.remove(orderId); + _logger.i('Order $orderId deleted from DB'); + } catch (e, stack) { + _logger.e('deleteOrder failed for $orderId', error: e, stackTrace: stack); + rethrow; + } } /// Delete all orders Future deleteAllOrders() async { - await _ordersStore.delete(_database); - } - - Future updateAction(String orderId, Action newAction) async { - final record = await _ordersStore.record(orderId).get(_database); - if (record == null) { - _logger.i("No such order $orderId"); - return; + try { + await _database.transaction((txn) async { + await _ordersStore.delete(txn); + }); + _messages.clear(); + _logger.i('All orders deleted'); + } catch (e, stack) { + _logger.e('deleteAllOrders failed', error: e, stackTrace: stack); + rethrow; } - record['order']['action'] = newAction.value; - await _ordersStore.record(orderId).put(_database, record); - } - - @override - void dispose() { - // If needed } + /// Update an entire order @override Future updateOrder(MostroMessage message) async { final orderId = message.id; if (orderId == null) { - throw ArgumentError('Cannot save an order with a null message.id'); + throw ArgumentError('Cannot update an order with a null message.id'); + } + + try { + await _database.transaction((txn) async { + final jsonMap = message.toJson(); + await _ordersStore.record(orderId).put(txn, jsonMap); + }); + // Update in-memory cache + _messages[orderId] = message; + _logger.i('Order $orderId updated in Sembast'); + } catch (e, stack) { + _logger.e('updateOrder failed for $orderId', error: e, stackTrace: stack); + rethrow; } - // Convert to JSON so we can store as a Map - final jsonMap = message.toJson(); - await _ordersStore.record(orderId).put(_database, jsonMap); - _logger.i('Order $orderId saved to Sembast'); + } + + @override + void dispose() { + // await _database.close(); } } diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart index b47892f2..9c7b4783 100644 --- a/lib/data/repositories/session_manager.dart +++ b/lib/data/repositories/session_manager.dart @@ -63,7 +63,6 @@ class SessionManager { _sessions[keyIndex] = session; // Persist it in the database await _sessionStorage.putSession(session); - return session; } diff --git a/lib/features/mostro/mostro_screen.dart b/lib/features/mostro/mostro_screen.dart index d3c59bb4..0171ad26 100644 --- a/lib/features/mostro/mostro_screen.dart +++ b/lib/features/mostro/mostro_screen.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; @@ -14,7 +13,7 @@ class MostroScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final nostrEvent = ref.read(orderRepositoryProvider).mostroInstance; - final mostroMessages = ref.read(mostroRepositoryProvider).allMessages; + final List mostroMessages = []; // ref.read(mostroStorageProvider).getAllOrders(); return nostrEvent == null ? Scaffold( diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 0e6124f6..e5de317a 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -9,21 +9,21 @@ import 'package:mostro_mobile/data/models/enums/cant_do_reason.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/peer.dart'; -import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; class AbstractOrderNotifier extends StateNotifier { - final MostroRepository orderRepository; + final MostroService mostroService; final Ref ref; final String orderId; StreamSubscription? orderSubscription; final logger = Logger(); AbstractOrderNotifier( - this.orderRepository, + this.mostroService, this.orderId, this.ref, ) : super(MostroMessage(action: Action.newOrder, id: orderId)); diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart index 1361b252..98bcc80f 100644 --- a/lib/features/order/notfiers/add_order_notifier.dart +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -5,7 +5,7 @@ import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.da import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; class AddOrderNotifier extends AbstractOrderNotifier { - AddOrderNotifier(super.orderRepository, super.orderId, super.ref); + AddOrderNotifier(super.mostroService, super.orderId, super.ref); @override Future subscribe(Stream stream) async { @@ -23,7 +23,7 @@ class AddOrderNotifier extends AbstractOrderNotifier { } } - // This method would be called when the order is confirmed. + // This method is called when the order is confirmed. Future confirmOrder(MostroMessage confirmedOrder) async { // Extract the confirmed (real) order id. final confirmedOrderId = confirmedOrder.id; @@ -40,11 +40,13 @@ class AddOrderNotifier extends AbstractOrderNotifier { .toInt(); final message = MostroMessage( - action: Action.newOrder, - id: null, - requestId: requestId, - payload: order); - final stream = await orderRepository.publishOrder(message); + action: Action.newOrder, + id: null, + requestId: requestId, + payload: order, + ); + final session = await mostroService.publishOrder(message); + final stream = mostroService.subscribe(session); await subscribe(stream); } } diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index ad379180..76eb043a 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -5,66 +5,78 @@ import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; class OrderNotifier extends AbstractOrderNotifier { - OrderNotifier(super.orderRepository, super.orderId, super.ref); + OrderNotifier(super.mostroService, super.orderId, super.ref); + + Future sync() async { + state = await mostroService.getOrderById(orderId) ?? state; + } Future resubscribe() async { - final stream = orderRepository.resubscribeOrder(orderId); - Timer? debounceTimer; - stream.listen((order) { - // Cancel any previously scheduled update. - debounceTimer?.cancel(); - // Schedule a new update after a debounce duration. - debounceTimer = Timer(const Duration(milliseconds: 300), () { - state = order; - debounceTimer?.cancel(); - subscribe(stream); - }); - }, onDone: () { - debounceTimer?.cancel(); - }, onError: handleError); + await sync(); + final session = mostroService.getSessionByOrderId(orderId); + final stream = mostroService.subscribe(session!); + await subscribe(stream); + } + + Future submitOrder(Order order) async { + final message = MostroMessage( + action: Action.newOrder, + id: null, + payload: order, + ); + final session = await mostroService.publishOrder(message); + final stream = mostroService.subscribe(session); + await subscribe(stream); } Future takeSellOrder( String orderId, int? amount, String? lnAddress) async { - final stream = - await orderRepository.takeSellOrder(orderId, amount, lnAddress); + final session = await mostroService.takeSellOrder( + orderId, + amount, + lnAddress, + ); + final stream = mostroService.subscribe(session); await subscribe(stream); } Future takeBuyOrder(String orderId, int? amount) async { - final stream = await orderRepository.takeBuyOrder(orderId, amount); + final session = await mostroService.takeBuyOrder( + orderId, + amount, + ); + final stream = mostroService.subscribe(session); await subscribe(stream); } Future sendInvoice(String orderId, String invoice, int? amount) async { - await orderRepository.sendInvoice(orderId, invoice, amount); + await mostroService.sendInvoice( + orderId, + invoice, + amount, + ); } Future cancelOrder() async { - await orderRepository.cancelOrder(orderId); - } - - Future submitOrder(Order order) async { - final message = - MostroMessage(action: Action.newOrder, id: null, payload: order); - final stream = await orderRepository.publishOrder(message); - await subscribe(stream); + await mostroService.cancelOrder(orderId); } Future sendFiatSent() async { - await orderRepository.sendFiatSent(orderId); + await mostroService.sendFiatSent(orderId); } Future releaseOrder() async { - await orderRepository.releaseOrder(orderId); + await mostroService.releaseOrder(orderId); } Future disputeOrder() async { - await orderRepository.disputeOrder(orderId); + await mostroService.disputeOrder(orderId); } - Future submitRating(int rating) async { - await orderRepository.submitRating(orderId, rating); - } - + Future submitRating(int rating) async { + await mostroService.submitRating( + orderId, + rating, + ); + } } diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index dd8e7d71..0ef8d8f6 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -8,7 +8,7 @@ import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; final orderNotifierProvider = StateNotifierProvider.family( (ref, orderId,) { - final repo = ref.read(mostroRepositoryProvider); + final repo = ref.read(mostroServiceProvider); return OrderNotifier( repo, orderId, @@ -20,7 +20,7 @@ final orderNotifierProvider = final addOrderNotifierProvider = StateNotifierProvider.family( (ref, orderId) { - final repo = ref.read(mostroRepositoryProvider); + final repo = ref.read(mostroServiceProvider); return AddOrderNotifier(repo, orderId, ref); }, ); diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index e34e38d2..b3306b40 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -158,9 +158,8 @@ class TradeDetailScreen extends ConsumerWidget { Widget _buildCountDownTime(DateTime expiration) { // If expiration has passed, the difference is negative => zero. final now = DateTime.now(); - final Duration difference = expiration.isAfter(now) - ? expiration.difference(now) - : const Duration(); + final Duration difference = + expiration.isAfter(now) ? expiration.difference(now) : const Duration(); // Display hours left final hoursLeft = difference.inHours.clamp(0, 9999); @@ -180,8 +179,6 @@ class TradeDetailScreen extends ConsumerWidget { /// Additional checks use `message.action` to refine which button to show. List _buildActionButtons( BuildContext context, WidgetRef ref, NostrEvent order) { - final orderDetailsNotifier = - ref.read(orderNotifierProvider(orderId).notifier); final message = ref.watch(orderNotifierProvider(orderId)); // The finite-state-machine approach: decide based on the order.status. @@ -189,7 +186,7 @@ class TradeDetailScreen extends ConsumerWidget { switch (order.status) { case Status.pending: case Status.waitingPayment: - // Usually, a pending or waitingPayment order can be canceled. + // Usually, a pending or waitingPayment order can be canceled. // Possibly the user could do more if they’re the buyer vs. seller, // but for simplicity we show CANCEL only. return [ @@ -198,8 +195,6 @@ class TradeDetailScreen extends ConsumerWidget { ]; case Status.waitingBuyerInvoice: - // Some code lumps in "settledHoldInvoice" and "active" together, - // but let’s keep them separate for clarity: case Status.settledHoldInvoice: case Status.active: return [ @@ -212,42 +207,16 @@ class TradeDetailScreen extends ConsumerWidget { // If the action is "addInvoice" => maybe show a button to push the invoice screen. if (message.action == actions.Action.addInvoice) - ElevatedButton( - onPressed: () async { - context.push('/add_invoice/$orderId'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('ADD INVOICE'), - ), - + _buildAddInvoiceButton(context), // If the order is waiting for buyer to confirm fiat was sent if (message.action == actions.Action.holdInvoicePaymentAccepted) - ElevatedButton( - onPressed: () async { - await orderDetailsNotifier.sendFiatSent(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('FIAT SENT'), - ), - + _buildFiatSentButton(ref), // If the user is the seller & the buyer is done => show release button if (message.action == actions.Action.buyerTookOrder) - ElevatedButton( - onPressed: () async { - await orderDetailsNotifier.releaseOrder(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('RELEASE SATS'), - ), - + _buildReleaseButton(ref), // If the user is ready to rate - if (message.action == actions.Action.rate) _buildRateButton(context), + if (message.action == actions.Action.rate) + _buildRateButton(context), ]; case Status.fiatSent: @@ -285,6 +254,51 @@ class TradeDetailScreen extends ConsumerWidget { } } + /// RELEASE + Widget _buildReleaseButton(WidgetRef ref) { + final orderDetailsNotifier = + ref.read(orderNotifierProvider(orderId).notifier); + + return ElevatedButton( + onPressed: () async { + await orderDetailsNotifier.releaseOrder(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('RELEASE SATS'), + ); + } + + /// FIAT_SENT + Widget _buildFiatSentButton(WidgetRef ref) { + final orderDetailsNotifier = + ref.read(orderNotifierProvider(orderId).notifier); + + return ElevatedButton( + onPressed: () async { + await orderDetailsNotifier.sendFiatSent(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('FIAT SENT'), + ); + } + + /// ADD INVOICE + Widget _buildAddInvoiceButton(BuildContext context) { + return ElevatedButton( + onPressed: () async { + context.push('/add_invoice/$orderId'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('ADD INVOICE'), + ); + } + /// CANCEL Widget _buildCancelButton(WidgetRef ref) { final notifier = ref.read(orderNotifierProvider(orderId).notifier); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 62b6f533..c180ea7a 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -5,9 +5,12 @@ import 'package:mostro_mobile/data/models/amount.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/data/models/payload.dart'; import 'package:mostro_mobile/data/models/payment_request.dart'; import 'package:mostro_mobile/data/models/rating_user.dart'; import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; @@ -16,18 +19,69 @@ import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; class MostroService { final NostrService _nostrService; final SessionNotifier _sessionManager; + final MostroStorage _messageStorage; final _logger = Logger(); Settings _settings; - MostroService(this._nostrService, this._sessionManager, this._settings); + MostroService(this._nostrService, this._sessionManager, this._settings, + this._messageStorage); + + Future getOrderById(String orderId) async { + return await _messageStorage.getOrderById(orderId); + } + + Future sync(Session session) async { + final filter = NostrFilter( + kinds: [1059], + p: [session.tradeKey.public], + ); + final events = await _nostrService.fecthEvents(filter); + List orders = []; + + for (final event in events) { + final decryptedEvent = await event.unWrap( + session.tradeKey.private, + ); + + if (decryptedEvent.content == null) { + _logger.i('Event ${decryptedEvent.id} content is null'); + continue; + } + + final result = jsonDecode(decryptedEvent.content!); + + if (result is! List) { + _logger.e('Event content ${decryptedEvent.content} should be a List'); + continue; + } + + final msgMap = result[0]; + + if (msgMap.containsKey('order')) { + final msg = MostroMessage.fromJson(msgMap['order']); + orders.add(msg); + } else if (msgMap.containsKey('cant-do')) { + //final msg = MostroMessage.fromJson(msgMap['cant-do']); + //orders.add(msg); + } else { + _logger.e('Result not found ${decryptedEvent.content}'); + } + } + + _messageStorage.addOrders(orders); + } Stream subscribe(Session session) { - final filter = NostrFilter(p: [session.tradeKey.public]); + final filter = NostrFilter( + kinds: [1059], + p: [session.tradeKey.public], + ); return _nostrService.subscribeToEvents(filter).asyncMap((event) async { _logger.i('Event received from Mostro: $event'); - final decryptedEvent = await _nostrService.decryptNIP59Event( - event, session.tradeKey.private); + final decryptedEvent = await event.unWrap( + session.tradeKey.private, + ); // Check event content is not null if (decryptedEvent.content == null) { @@ -61,6 +115,7 @@ class MostroService { session.orderId = msg.id; await _sessionManager.saveSession(session); } + await _saveMessage(msg); return msg; } @@ -74,49 +129,95 @@ class MostroService { }); } + Future _saveMessage(MostroMessage message) async { + await _messageStorage.addOrder(message); + } + Session? getSessionByOrderId(String orderId) { return _sessionManager.getSessionByOrderId(orderId); } + Future takeBuyOrder(String orderId, int? amount) async { + final amt = amount != null ? Amount(amount: amount) : null; + return await publishOrder( + MostroMessage( + action: Action.takeBuy, + id: orderId, + payload: amt, + ), + ); + } + Future takeSellOrder( String orderId, int? amount, String? lnAddress) async { final payload = lnAddress != null - ? PaymentRequest(order: null, lnInvoice: lnAddress, amount: amount) + ? PaymentRequest( + order: null, + lnInvoice: lnAddress, + amount: amount, + ) : amount != null ? Amount(amount: amount) : null; return await publishOrder( - MostroMessage(action: Action.takeSell, id: orderId, payload: payload)); + MostroMessage( + action: Action.takeSell, + id: orderId, + payload: payload, + ), + ); } Future sendInvoice(String orderId, String invoice, int? amount) async { - final payload = - PaymentRequest(order: null, lnInvoice: invoice, amount: amount); - await publishOrder(MostroMessage( - action: Action.addInvoice, id: orderId, payload: payload)); - } - - Future takeBuyOrder(String orderId, int? amount) async { - final amt = amount != null ? Amount(amount: amount) : null; - return await publishOrder( - MostroMessage(action: Action.takeBuy, id: orderId, payload: amt)); + final payload = PaymentRequest( + order: null, + lnInvoice: invoice, + amount: amount, + ); + await publishOrder( + MostroMessage( + action: Action.addInvoice, + id: orderId, + payload: payload, + ), + ); } Future cancelOrder(String orderId) async { - await publishOrder(MostroMessage(action: Action.cancel, id: orderId)); + await publishOrder( + MostroMessage( + action: Action.cancel, + id: orderId, + ), + ); } Future sendFiatSent(String orderId) async { - await publishOrder(MostroMessage(action: Action.fiatSent, id: orderId)); + await publishOrder( + MostroMessage( + action: Action.fiatSent, + id: orderId, + ), + ); } Future releaseOrder(String orderId) async { - await publishOrder(MostroMessage(action: Action.release, id: orderId)); + await publishOrder( + MostroMessage( + action: Action.release, + id: orderId, + ), + ); } Future disputeOrder(String orderId) async { - await publishOrder(MostroMessage(action: Action.dispute, id: orderId)); + await publishOrder( + MostroMessage( + action: Action.dispute, + id: orderId, + ), + ); } Future submitRating(String orderId, int rating) async { @@ -140,8 +241,11 @@ class MostroService { content = order.serialize(); } _logger.i('Publishing order: $content'); - final event = - await createNIP59Event(content, _settings.mostroPublicKey, session); + final event = await createNIP59Event( + content, + _settings.mostroPublicKey, + session, + ); await _nostrService.publishEvent(event); return session; } diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index f074294e..742a57e9 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -44,7 +44,7 @@ class NostrService { Future updateSettings(Settings newSettings) async { settings = newSettings.copyWith(); final relays = Nostr.instance.services.relays.relaysList; - if (!ListEquality().equals(relays, settings.relays) ) { + if (!ListEquality().equals(relays, settings.relays)) { _logger.i('Updating relays...'); await init(); } @@ -62,8 +62,10 @@ class NostrService { } try { - await _nostr.services.relays.sendEventToRelaysAsync(event, - timeout: Config.nostrConnectionTimeout); + await _nostr.services.relays.sendEventToRelaysAsync( + event, + timeout: Config.nostrConnectionTimeout, + ); _logger.i('Event published successfully'); } catch (e) { _logger.w('Failed to publish event: $e'); @@ -71,6 +73,20 @@ class NostrService { } } + Future> fecthEvents(NostrFilter filter) async { + if (!_isInitialized) { + throw Exception('Nostr is not initialized. Call init() first.'); + } + + final request = NostrRequest(filters: [filter]); + return + await _nostr.services.relays.startEventsSubscriptionAsync( + request: request, + timeout: Config.nostrConnectionTimeout, + ); + + } + Stream subscribeToEvents(NostrFilter filter) { if (!_isInitialized) { throw Exception('Nostr is not initialized. Call init() first.'); @@ -113,7 +129,10 @@ class NostrService { } return NostrUtils.createNIP59Event( - content, recipientPubKey, senderPrivateKey); + content, + recipientPubKey, + senderPrivateKey, + ); } Future decryptNIP59Event( @@ -122,24 +141,38 @@ class NostrService { throw Exception('Nostr is not initialized. Call init() first.'); } - return NostrUtils.decryptNIP59Event(event, privateKey); + return NostrUtils.decryptNIP59Event( + event, + privateKey, + ); } Future createRumor(NostrKeyPairs senderKeyPair, String wrapperKey, String recipientPubKey, String content) async { return NostrUtils.createRumor( - senderKeyPair, wrapperKey, recipientPubKey, content); + senderKeyPair, + wrapperKey, + recipientPubKey, + content, + ); } Future createSeal(NostrKeyPairs senderKeyPair, String wrapperKey, String recipientPubKey, String encryptedContent) async { return NostrUtils.createSeal( - senderKeyPair, wrapperKey, recipientPubKey, encryptedContent); + senderKeyPair, + wrapperKey, + recipientPubKey, + encryptedContent, + ); } Future createWrap(NostrKeyPairs wrapperKeyPair, String sealedContent, String recipientPubKey) async { return NostrUtils.createWrap( - wrapperKeyPair, sealedContent, recipientPubKey); + wrapperKeyPair, + sealedContent, + recipientPubKey, + ); } } diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 8d845432..146b8f76 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -32,6 +32,7 @@ final appInitializerProvider = FutureProvider((ref) async { }); for (final session in sessionManager.sessions) { + await mostroService.sync(session); if (session.orderId != null) { final order = ref.watch(orderNotifierProvider(session.orderId!).notifier); order.resubscribe(); diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index ca0e66ac..bb49e26e 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -1,5 +1,4 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; @@ -10,13 +9,8 @@ final mostroServiceProvider = Provider((ref) { final sessionStorage = ref.read(sessionNotifierProvider.notifier); final nostrService = ref.read(nostrServiceProvider); final settings = ref.read(settingsProvider); - final mostroService = MostroService(nostrService, sessionStorage, settings); - return mostroService; -}); - -final mostroRepositoryProvider = Provider((ref) { - final mostroService = ref.read(mostroServiceProvider); final mostroDatabase = ref.read(mostroStorageProvider); - final mostroRepository = MostroRepository(mostroService, mostroDatabase); - return mostroRepository; + final mostroService = + MostroService(nostrService, sessionStorage, settings, mostroDatabase); + return mostroService; }); diff --git a/test/notifiers/add_order_notifier_test.dart b/test/notifiers/add_order_notifier_test.dart index 9f99d8c0..5530298a 100644 --- a/test/notifiers/add_order_notifier_test.dart +++ b/test/notifiers/add_order_notifier_test.dart @@ -17,14 +17,14 @@ void main() { group('AddOrderNotifier - Mockito tests', () { late ProviderContainer container; - late MockMostroRepository mockRepository; + late MockMostroService mockRepository; late MockOpenOrdersRepository mockOrdersRepository; const testUuid = "test_uuid"; setUp(() { container = ProviderContainer(); - mockRepository = MockMostroRepository(); + mockRepository = MockMostroService(); mockOrdersRepository = MockOpenOrdersRepository(); }); @@ -70,7 +70,7 @@ void main() { // Override the repository provider with our mock. container = ProviderContainer(overrides: [ - mostroRepositoryProvider.overrideWithValue(mockRepository), + mostroServiceProvider.overrideWithValue(mockRepository), orderRepositoryProvider.overrideWithValue(mockOrdersRepository), ]); From 686d8533dd14994a964f442076cf0e25a7fdb623 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Sat, 22 Mar 2025 01:08:04 +1000 Subject: [PATCH 083/149] Added BaseStorage abstract class Refactored Session Management Refactored Order Hnadling Major breaking changes --- lib/data/models/enums/role.dart | 29 ++++ lib/data/models/mostro_message.dart | 38 ++++- lib/data/models/payload.dart | 6 +- lib/data/models/session.dart | 14 +- lib/data/repositories/base_storage.dart | 97 +++++++++++ lib/data/repositories/mostro_storage.dart | 99 +++--------- lib/data/repositories/session_manager.dart | 133 --------------- lib/data/repositories/session_storage.dart | 139 ++++++---------- .../key_manager/key_management_screen.dart | 4 +- lib/features/key_manager/key_manager.dart | 34 +++- .../key_manager/key_manager_provider.dart | 21 ++- lib/features/key_manager/key_notifier.dart | 12 ++ .../notfiers/abstract_order_notifier.dart | 33 ++-- .../order/notfiers/add_order_notifier.dart | 7 +- .../order/notfiers/order_notifier.dart | 3 + lib/features/settings/settings.dart | 24 +-- lib/features/settings/settings_notifier.dart | 10 +- lib/features/settings/settings_screen.dart | 6 +- .../trades/screens/trade_detail_screen.dart | 47 ++++-- .../widgets/mostro_message_detail_widget.dart | 12 +- .../trades/widgets/trades_list_item.dart | 18 ++- lib/services/mostro_service.dart | 81 +++++----- lib/shared/notifiers/session_notifier.dart | 152 +++++++++++++++--- lib/shared/providers/app_init_provider.dart | 9 +- .../providers/mostro_storage_provider.dart | 2 +- .../providers/session_manager_provider.dart | 22 +-- .../providers/session_storage_provider.dart | 2 +- 27 files changed, 581 insertions(+), 473 deletions(-) create mode 100644 lib/data/models/enums/role.dart create mode 100644 lib/data/repositories/base_storage.dart delete mode 100644 lib/data/repositories/session_manager.dart create mode 100644 lib/features/key_manager/key_notifier.dart diff --git a/lib/data/models/enums/role.dart b/lib/data/models/enums/role.dart new file mode 100644 index 00000000..cb12e3de --- /dev/null +++ b/lib/data/models/enums/role.dart @@ -0,0 +1,29 @@ +enum Role { + buyer('buyer'), + seller('seller'), + admin('admin'); + + final String value; + + const Role(this.value); + + /// Converts a string value to its corresponding Roie enum value. + /// + /// Throws an ArgumentError if the string doesn't match any Role value. + static final _valueMap = { + for (var action in Role.values) action.value: action + }; + + static Role fromString(String value) { + final action = _valueMap[value]; + if (action == null) { + throw ArgumentError('Invalid Role: $value'); + } + return action; + } + + @override + String toString() { + return value; + } +} diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 64923daf..9a084a7b 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -2,9 +2,11 @@ import 'dart:convert'; import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/nostr/core/key_pairs.dart'; +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/payload.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class MostroMessage { String? id; @@ -13,13 +15,13 @@ class MostroMessage { int? tradeIndex; T? _payload; - MostroMessage( - {required this.action, - this.requestId, - this.id, - T? payload, - this.tradeIndex}) - : _payload = payload; + MostroMessage({ + required this.action, + this.requestId, + this.id, + T? payload, + this.tradeIndex, + }) : _payload = payload; Map toJson() { Map json = { @@ -102,4 +104,26 @@ class MostroMessage { final content = '[$serializedEvent, $signature]'; return content; } + + Future wrap({ + required NostrKeyPairs tradeKey, + required String recipientPubKey, + NostrKeyPairs? masterKey, + int? keyIndex, + }) async { + this.tradeIndex = keyIndex; + final content = serialize(keyPair: masterKey != null ? tradeKey : null); + final keySet = masterKey ?? tradeKey; + + final encryptedContent = await NostrUtils.createRumor( + tradeKey, keySet.private, recipientPubKey, content); + + final wrapperKeyPair = NostrUtils.generateKeyPair(); + + String sealedContent = await NostrUtils.createSeal( + keySet, wrapperKeyPair.private, recipientPubKey, encryptedContent); + + return await NostrUtils.createWrap( + wrapperKeyPair, sealedContent, recipientPubKey); + } } diff --git a/lib/data/models/payload.dart b/lib/data/models/payload.dart index f572f6bc..85f00a9e 100644 --- a/lib/data/models/payload.dart +++ b/lib/data/models/payload.dart @@ -3,6 +3,7 @@ import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/payment_request.dart'; import 'package:mostro_mobile/data/models/peer.dart'; +import 'package:mostro_mobile/data/models/rating_user.dart'; abstract class Payload { String get type; @@ -19,7 +20,10 @@ abstract class Payload { return Peer.fromJson(json['peer']); } else if (json.containsKey('dispute')) { return Dispute.fromJson(json['dispute']); + } else if (json.containsKey('rating_user')) { + return RatingUser.fromJson(json['rating_user']); + } else { + throw UnsupportedError('Unknown payload type'); } - throw UnsupportedError('Unknown payload type'); } } diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 58d80271..89754d70 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -1,5 +1,5 @@ import 'package:dart_nostr/dart_nostr.dart'; -import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/peer.dart'; /// Represents a User session @@ -12,7 +12,7 @@ class Session { final bool fullPrivacy; final DateTime startTime; String? orderId; - OrderType? orderType; + Role? role; Peer? peer; Session({ @@ -22,17 +22,17 @@ class Session { required this.fullPrivacy, required this.startTime, this.orderId, - this.orderType, + this.role, this.peer, }); Map toJson() => { - 'trade_key': tradeKey.private, + 'trade_key': tradeKey.public, 'key_index': keyIndex, 'full_privacy': fullPrivacy, 'start_time': startTime.toIso8601String(), 'order_id': orderId, - 'order_type': orderType?.value, + 'role': role?.value, 'peer': peer?.publicKey, }; @@ -44,9 +44,7 @@ class Session { fullPrivacy: json['full_privacy'], startTime: DateTime.parse(json['start_time']), orderId: json['order_id'], - orderType: json['order_type'] != null - ? OrderType.fromString(json['order_type']) - : null, + role: json['role'] != null ? Role.fromString(json['role']) : null, peer: json['peer'] != null ? Peer(publicKey: json['peer']) : null, ); } diff --git a/lib/data/repositories/base_storage.dart b/lib/data/repositories/base_storage.dart new file mode 100644 index 00000000..92de7439 --- /dev/null +++ b/lib/data/repositories/base_storage.dart @@ -0,0 +1,97 @@ +import 'package:sembast/sembast.dart'; + +/// A base interface for a Sembast-backed storage of items of type [T]. +abstract class BaseStorage { + /// Reference to the Sembast database. + final Database db; + + /// The Sembast store reference (string key, Map<String, dynamic> value). + final StoreRef> store; + + BaseStorage(this.db, this.store); + + /// Convert a domain object [T] to JSON-ready Map. + Map toDbMap(T item); + + /// Decode a JSON Map into domain object [T]. + T fromDbMap(String key, Map jsonMap); + + /// Insert or update an item in the store. The item is identified by [id]. + Future putItem(String id, T item) async { + final jsonMap = toDbMap(item); + await db.transaction((txn) async { + await store.record(id).put(txn, jsonMap); + }); + } + + /// Retrieve an item by [id]. + Future getItem(String id) async { + final record = await store.record(id).get(db); + if (record == null) return null; + return fromDbMap(id, record); + } + + /// Return all items in the store. + Future> getAllItems() async { + final records = await store.find(db); + final result = []; + for (final record in records) { + try { + final item = fromDbMap(record.key, record.value); + result.add(item); + } catch (e) { + // Optionally handle or log parse errors + } + } + return result; + } + + /// Delete an item by [id]. + Future deleteItem(String id) async { + await db.transaction((txn) async { + await store.record(id).delete(txn); + }); + } + + /// Delete all items in the store. + Future deleteAllItems() async { + await db.transaction((txn) async { + await store.delete(txn); + }); + } + + /// Delete items that match a predicate [filter]. + /// Return the list of deleted IDs. + Future> deleteWhere(bool Function(T) filter, + {int? maxBatchSize}) async { + final toDelete = []; + final records = await store.find(db); + + for (final record in records) { + if (maxBatchSize != null && toDelete.length >= maxBatchSize) { + break; + } + try { + final item = fromDbMap(record.key, record.value); + if (filter(item)) { + toDelete.add(record.key); + } + } catch (_) { + // Could not parse => also consider removing or ignoring + toDelete.add(record.key); + } + } + + // Remove the matched records in a transaction + await db.transaction((txn) async { + for (final key in toDelete) { + await store.record(key).delete(txn); + } + }); + + return toDelete; + } + + /// If needed, close or clean up resources here. + void dispose() {} +} diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 12011b6c..3e3d7301 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -1,28 +1,24 @@ import 'dart:async'; -import 'dart:convert'; import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/repositories/base_storage.dart'; import 'package:sembast/sembast.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; -class MostroStorage implements OrderRepository { +class MostroStorage extends BaseStorage { final Logger _logger = Logger(); - final Database _database; - /// In-memory cache for quick lookups. - final Map _messages = {}; - - final StoreRef> _ordersStore = - stringMapStoreFactory.store('orders'); - - MostroStorage(this._database); + MostroStorage({ + required Database db, + }) : super( + db, + stringMapStoreFactory.store('orders'), + ); Future init() async { await getAllOrders(); } /// Save or update a MostroMessage - @override Future addOrder(MostroMessage message) async { final orderId = message.id; if (orderId == null) { @@ -30,17 +26,11 @@ class MostroStorage implements OrderRepository { } try { - await _database.transaction((txn) async { - final jsonMap = message.toJson(); - await _ordersStore.record(orderId).put(txn, jsonMap); - }); - - // Update in-memory cache - _messages[orderId] = message; - _logger.i('Order $orderId saved to Sembast'); + await putItem(orderId, message); + _logger.i('Order $orderId saved'); } catch (e, stack) { _logger.e('addOrder failed for $orderId', error: e, stackTrace: stack); - rethrow; // Rethrow or handle the error as needed + rethrow; } } @@ -51,22 +41,9 @@ class MostroStorage implements OrderRepository { } /// Retrieve an order by ID - @override Future getOrderById(String orderId) async { - // First check in-memory cache - if (_messages.containsKey(orderId)) { - return _messages[orderId]; - } - try { - final record = await _ordersStore.record(orderId).get(_database); - if (record == null) { - return null; - } - final msg = MostroMessage.deserialized(jsonEncode(record)); - // Update in-memory cache - _messages[orderId] = msg; - return msg; + return await getItem(orderId); } catch (e, stack) { _logger.e('Error deserializing order $orderId', error: e, stackTrace: stack); @@ -75,23 +52,9 @@ class MostroStorage implements OrderRepository { } /// Return all orders - @override Future> getAllOrders() async { try { - final records = await _ordersStore.find(_database); - final results = []; - for (final record in records) { - try { - final msg = MostroMessage.deserialized(jsonEncode(record.value)); - results.add(msg); - // Update or populate in-memory cache - _messages[record.key] = msg; - } catch (e, stack) { - _logger.e('Error deserializing order with key ${record.key}', - error: e, stackTrace: stack); - } - } - return results; + return await getAllItems(); } catch (e, stack) { _logger.e('getAllOrders failed', error: e, stackTrace: stack); return []; @@ -99,13 +62,9 @@ class MostroStorage implements OrderRepository { } /// Delete an order from DB - @override Future deleteOrder(String orderId) async { try { - await _database.transaction((txn) async { - await _ordersStore.record(orderId).delete(txn); - }); - _messages.remove(orderId); + await deleteItem(orderId); _logger.i('Order $orderId deleted from DB'); } catch (e, stack) { _logger.e('deleteOrder failed for $orderId', error: e, stackTrace: stack); @@ -116,10 +75,7 @@ class MostroStorage implements OrderRepository { /// Delete all orders Future deleteAllOrders() async { try { - await _database.transaction((txn) async { - await _ordersStore.delete(txn); - }); - _messages.clear(); + await deleteAllItems(); _logger.i('All orders deleted'); } catch (e, stack) { _logger.e('deleteAllOrders failed', error: e, stackTrace: stack); @@ -127,30 +83,13 @@ class MostroStorage implements OrderRepository { } } - /// Update an entire order @override - Future updateOrder(MostroMessage message) async { - final orderId = message.id; - if (orderId == null) { - throw ArgumentError('Cannot update an order with a null message.id'); - } - - try { - await _database.transaction((txn) async { - final jsonMap = message.toJson(); - await _ordersStore.record(orderId).put(txn, jsonMap); - }); - // Update in-memory cache - _messages[orderId] = message; - _logger.i('Order $orderId updated in Sembast'); - } catch (e, stack) { - _logger.e('updateOrder failed for $orderId', error: e, stackTrace: stack); - rethrow; - } + MostroMessage fromDbMap(String key, Map jsonMap) { + return MostroMessage.fromJson(jsonMap); } @override - void dispose() { - // await _database.close(); + Map toDbMap(MostroMessage item) { + return item.toJson(); } } diff --git a/lib/data/repositories/session_manager.dart b/lib/data/repositories/session_manager.dart deleted file mode 100644 index 9c7b4783..00000000 --- a/lib/data/repositories/session_manager.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'dart:async'; -import 'package:logger/logger.dart'; -import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/data/repositories/session_storage.dart'; -import 'package:mostro_mobile/features/key_manager/key_manager.dart'; -import 'package:mostro_mobile/features/settings/settings.dart'; - -class SessionManager { - final Logger _logger = Logger(); - - final KeyManager _keyManager; - final SessionStorage _sessionStorage; - Settings _settings; - - // In-memory session cache - final Map _sessions = {}; - - Timer? _cleanupTimer; - final int sessionExpirationHours = 48; - static const cleanupIntervalMinutes = 30; - static const maxBatchSize = 100; - - /// Returns all in-memory sessions. - List get sessions => _sessions.values.toList(); - - SessionManager(this._keyManager, this._sessionStorage, this._settings) { - _initializeCleanup(); - } - - /// Load all sessions at startup and populate the in-memory map. - Future init() async { - final allSessions = await _sessionStorage.getAllSessions(); - for (final session in allSessions) { - _sessions[session.keyIndex] = session; - } - } - - Future reset() async { - await _sessionStorage.deleteAllSessions(); - _sessions.clear(); - } - - void updateSettings(Settings settings) { - _settings = settings.copyWith(); - } - - /// Creates a new session, storing it both in memory and in the database. - Future newSession({String? orderId}) async { - final masterKey = await _keyManager.getMasterKey(); - final keyIndex = await _keyManager.getCurrentKeyIndex(); - final tradeKey = await _keyManager.deriveTradeKey(); - - final session = Session( - startTime: DateTime.now(), - masterKey: masterKey, - keyIndex: keyIndex, - tradeKey: tradeKey, - fullPrivacy: _settings.fullPrivacyMode, - orderId: orderId, - ); - - // Cache it in memory - _sessions[keyIndex] = session; - // Persist it in the database - await _sessionStorage.putSession(session); - return session; - } - - /// Update a session in both memory and the database. - Future saveSession(Session session) async { - _sessions[session.keyIndex] = session; - await _sessionStorage.putSession(session); - } - - /// Retrieve the first session that matches a given orderId (from the in-memory map). - Session? getSessionByOrderId(String orderId) { - try { - return _sessions.values.firstWhere((s) => s.orderId == orderId); - } on StateError { - return null; - } - } - - /// Retrieve a session by its keyIndex (checks memory first, then DB). - Future loadSession(int keyIndex) async { - if (_sessions.containsKey(keyIndex)) { - return _sessions[keyIndex]; - } - final session = await _sessionStorage.getSession(keyIndex); - if (session != null) { - _sessions[keyIndex] = session; - } - return session; - } - - /// Removes a session from memory and the database. - Future deleteSession(int sessionId) async { - _sessions.remove(sessionId); - await _sessionStorage.deleteSession(sessionId); - } - - /// Periodically clear out expired sessions. - Future clearExpiredSessions() async { - try { - final removedIds = await _sessionStorage.deleteExpiredSessions( - sessionExpirationHours, - maxBatchSize, - ); - // Remove them from the in-memory map - for (final id in removedIds) { - _sessions.remove(id); - } - } catch (e) { - _logger.e('Error during session cleanup: $e'); - } - } - - void _initializeCleanup() { - _cleanupTimer?.cancel(); - // Perform an initial cleanup - clearExpiredSessions(); - // Schedule periodic cleanup - _cleanupTimer = Timer.periodic( - const Duration(minutes: cleanupIntervalMinutes), - (_) => clearExpiredSessions(), - ); - } - - /// Dispose resources (e.g., timers) when no longer needed. - void dispose() { - _cleanupTimer?.cancel(); - } -} diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index 7c56868e..ec93123a 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -1,116 +1,75 @@ import 'package:dart_nostr/nostr/core/key_pairs.dart'; +import 'package:mostro_mobile/data/repositories/base_storage.dart'; import 'package:sembast/sembast.dart'; -import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/key_manager/key_manager.dart'; import 'package:sembast/utils/value_utils.dart'; -class SessionStorage { - final Database _database; +class SessionStorage extends BaseStorage { final KeyManager _keyManager; - final _logger = Logger(); - - // Store reference for sessions - final StoreRef> _store = - intMapStoreFactory.store('sessions'); SessionStorage( - this._database, - this._keyManager, - ); - - Future> getAllSessions() async { - final records = await _store.find(_database); - final sessions = []; - for (final record in records) { - try { - final session = await _decodeSession(record.value); - _logger.i('Decoded session ${session.toJson()}'); - sessions.add(session); - } catch (e) { - _logger.e('Error decoding session for key ${record.key}: $e'); - deleteSession(record.key); - } - } - return sessions; - } - - /// Retrieves one session by keyIndex. - Future getSession(int keyIndex) async { - final record = await _store.record(keyIndex).get(_database); - if (record == null) { - return null; - } - try { - return await _decodeSession(record); - } catch (e) { - _logger.e('Error decoding session index $keyIndex: $e'); - return null; - } - } - - /// Saves (inserts or updates) a session in the database. - Future putSession(Session session) async { - final jsonMap = session.toJson(); - // Use the session's keyIndex as the DB key - await _store.record(session.keyIndex).put(_database, jsonMap); + this._keyManager, { + required Database db, + }) : super( + db, + stringMapStoreFactory.store('sessions'), + ); + + @override + Map toDbMap(Session session) { + // Convert Session -> JSON + return session.toJson(); } - /// Deletes a specific session from the database. - Future deleteSession(int keyIndex) async { - await _store.record(keyIndex).delete(_database); + @override + Session fromDbMap(String key, Map jsonMap) { + // Re-derive or do any specialized logic + return _decodeSession(key, jsonMap); } - Future deleteAllSessions() async { - await _store.delete(_database); - } + /// A specialized decode that re-derives keys or changes the map structure + Session _decodeSession(String key, Map map) { + final clone = cloneMap(map); - /// Finds and deletes sessions considered expired, returning a list of deleted IDs. - Future> deleteExpiredSessions( - int sessionExpirationHours, int maxBatchSize) async { - final now = DateTime.now(); - final records = await _store.find(_database); - final removedIds = []; + // Fetch Master Key from KeyManager + final masterKey = _keyManager.masterKeyPair; - for (final record in records) { - if (removedIds.length >= maxBatchSize) break; + final keyIndex = map['key_index']; + final tradeKey = map['trade_key']; - try { - final sessionMap = record.value; - final startTimeStr = sessionMap['start_time'] as String?; - if (startTimeStr != null) { - final startTime = DateTime.parse(startTimeStr); - if (now.difference(startTime).inHours >= sessionExpirationHours) { - await _store.record(record.key).delete(_database); - removedIds.add(record.key); - } - } - } catch (e) { - // Possibly remove corrupted record - _logger.e('Error processing session ${record.key}: $e'); - await _store.record(record.key).delete(_database); - removedIds.add(record.key); - } + final tradeKeyPair = _keyManager.deriveTradeKeyPair(keyIndex); + if (tradeKeyPair.public != tradeKey) { + throw ArgumentError('Trade key does not match derived key'); } + clone['trade_key'] = NostrKeyPairs(private: tradeKey); + clone['master_key'] = masterKey; - return removedIds; + return Session.fromJson(clone); } - /// Rebuilds a [Session] object from the DB record by re-deriving keys. - Future _decodeSession(Map map) async { - //final index = map['key_index'] as int; + Future putSession(Session session) async { + if (session.orderId == null) { + throw ArgumentError('Cannot store a session with an empty orderId'); + } + await putItem(session.orderId!, session); + } - // Re-derive trade key from index - // final tradeKey = await _keyManager.deriveTradeKeyFromIndex(index); - // Re-get masterKey (potentially from secure storage/caching) - final masterKey = await _keyManager.getMasterKey(); - final tradeKey = map['trade_key']; + /// Shortcut to get a single session by its ID. + Future getSession(String sessionId) => getItem(sessionId); - var clone = cloneMap(map); + /// Shortcut to get all sessions (direct pass-through). + Future> getAllSessions() => getAllItems(); - clone['trade_key'] = NostrKeyPairs(private: tradeKey); - clone['master_key'] = masterKey; + /// Shortcut to remove a specific session by its ID. + Future deleteSession(String sessionId) => deleteItem(sessionId); - return Session.fromJson(clone); + Future> deleteExpiredSessions( + int sessionExpirationHours, int maxBatchSize) { + final now = DateTime.now(); + return deleteWhere((session) { + final startTime = session.startTime; + return now.difference(startTime).inHours >= sessionExpirationHours; + }, maxBatchSize: maxBatchSize); } } diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index 9b2214be..b31ba8de 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -36,8 +36,8 @@ class _KeyManagementScreenState extends ConsumerState { final keyManager = ref.read(keyManagerProvider); final hasMaster = await keyManager.hasMasterKey(); if (hasMaster) { - final masterKeyPairs = await keyManager.getMasterKey(); - _masterKey = masterKeyPairs.private; + final masterKeyPairs = keyManager.masterKeyPair; + _masterKey = masterKeyPairs?.private; _mnemonic = await keyManager.getMnemonic(); _tradeKeyIndex = await keyManager.getCurrentKeyIndex(); } else { diff --git a/lib/features/key_manager/key_manager.dart b/lib/features/key_manager/key_manager.dart index 42b8f89d..f11075f4 100644 --- a/lib/features/key_manager/key_manager.dart +++ b/lib/features/key_manager/key_manager.dart @@ -7,9 +7,25 @@ class KeyManager { final KeyStorage _storage; final KeyDerivator _derivator; + NostrKeyPairs? masterKeyPair; + String? _masterKeyHex; + int? tradeKeyIndex; + KeyManager(this._storage, this._derivator); + Future init() async { + if (!await hasMasterKey()) { + await generateAndStoreMasterKey(); + } + masterKeyPair = await _getMasterKey(); + _masterKeyHex = await _storage.readMasterKey(); + tradeKeyIndex = await _storage.readTradeKeyIndex(); + } + Future hasMasterKey() async { + if (masterKeyPair != null) { + return true; + } final masterKeyHex = await _storage.readMasterKey(); return masterKeyHex != null; } @@ -35,7 +51,7 @@ class KeyManager { /// Retrieve the master key from storage, returning NostrKeyPairs /// or throws a MasterKeyNotFoundException if not found - Future getMasterKey() async { + Future _getMasterKey() async { final masterKeyHex = await _storage.readMasterKey(); if (masterKeyHex == null) { throw MasterKeyNotFoundException('No master key found in secure storage'); @@ -65,13 +81,25 @@ class KeyManager { return NostrKeyPairs(private: tradePrivateHex); } + NostrKeyPairs deriveTradeKeyPair(int index) { + final tradePrivateHex = + _derivator.derivePrivateKey(_masterKeyHex!, index); + + return NostrKeyPairs(private: tradePrivateHex); + } + /// Derive a trade key for a specific index Future deriveTradeKeyFromIndex(int index) async { final masterKeyHex = await _storage.readMasterKey(); if (masterKeyHex == null) { - throw MasterKeyNotFoundException('No master key found in secure storage'); + throw MasterKeyNotFoundException( + 'No master key found in secure storage', + ); } - final tradePrivateHex = _derivator.derivePrivateKey(masterKeyHex, index); + final tradePrivateHex = _derivator.derivePrivateKey( + masterKeyHex, + index, + ); return NostrKeyPairs(private: tradePrivateHex); } diff --git a/lib/features/key_manager/key_manager_provider.dart b/lib/features/key_manager/key_manager_provider.dart index a5ff9caa..e3e665f5 100644 --- a/lib/features/key_manager/key_manager_provider.dart +++ b/lib/features/key_manager/key_manager_provider.dart @@ -1,7 +1,10 @@ +import 'package:dart_nostr/nostr/core/key_pairs.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; import 'package:mostro_mobile/features/key_manager/key_manager.dart'; +import 'package:mostro_mobile/features/key_manager/key_notifier.dart'; import 'package:mostro_mobile/features/key_manager/key_storage.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/storage_providers.dart'; final keyManagerProvider = Provider((ref) { @@ -12,4 +15,20 @@ final keyManagerProvider = Provider((ref) { KeyStorage(secureStorage: secureStorage, sharedPrefs: sharedPrefs); final keyDerivator = KeyDerivator("m/44'/1237'/38383'/0"); return KeyManager(keyStorage, keyDerivator); -}); \ No newline at end of file +}); + +// Provide the KeyNotifier + current master key +final masterKeyNotifierProvider = + StateNotifierProvider((ref) { + final manager = ref.watch(keyManagerProvider); + final settings = ref.watch(settingsProvider); + return KeyNotifier(manager, settings.copyWith()); +}); + +final tradeKeyNotifierProvider = + StateNotifierProvider.family((ref, userSessionId) { + final manager = ref.watch(keyManagerProvider); + + final settings = ref.watch(settingsProvider); + return KeyNotifier(manager, settings.copyWith()); +}); diff --git a/lib/features/key_manager/key_notifier.dart b/lib/features/key_manager/key_notifier.dart new file mode 100644 index 00000000..ef4294ed --- /dev/null +++ b/lib/features/key_manager/key_notifier.dart @@ -0,0 +1,12 @@ +import 'package:dart_nostr/nostr/core/key_pairs.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; + +class KeyNotifier extends StateNotifier { + final KeyManager _keyManager; + final Settings _settings; + + KeyNotifier(this._keyManager, this._settings) + : super(_keyManager.masterKeyPair); +} diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index e5de317a..43f1baaf 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -5,7 +5,6 @@ import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; -import 'package:mostro_mobile/data/models/enums/cant_do_reason.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/peer.dart'; @@ -21,6 +20,8 @@ class AbstractOrderNotifier extends StateNotifier { final String orderId; StreamSubscription? orderSubscription; final logger = Logger(); + Order? order; + Peer? peer; AbstractOrderNotifier( this.mostroService, @@ -30,8 +31,14 @@ class AbstractOrderNotifier extends StateNotifier { Future subscribe(Stream stream) async { try { - orderSubscription = stream.listen((order) { - state = order; + orderSubscription = stream.listen((event) { + if (event.payload is CantDo) { + handleCantDo(event.getPayload()); + return; + } + state = event; + event.payload is Order ? order = event.getPayload() : null; + event.payload is Peer ? peer = event.getPayload() : null; handleOrderUpdate(); }); } catch (e) { @@ -41,17 +48,13 @@ class AbstractOrderNotifier extends StateNotifier { void handleError(Object err) { logger.e(err); - if (state.payload is CantDo) { - final cantdo = state.getPayload()!; + } - switch (cantdo.cantDoReason) { - case CantDoReason.outOfRangeSatsAmount: - break; - case CantDoReason.outOfRangeFiatAmount: - break; - default: - } - } + void handleCantDo(CantDo? cantDo) { + final notifProvider = ref.read(notificationProvider.notifier); + notifProvider.showInformation(Action.cantDo, values: { + 'action': cantDo?.cantDoReason.toString(), + }); } void handleOrderUpdate() { @@ -118,6 +121,10 @@ class AbstractOrderNotifier extends StateNotifier { }); break; case Action.holdInvoicePaymentSettled: + notifProvider.showInformation(state.action, values: { + 'buyer_npub': order?.buyerTradePubkey, + }); + break; case Action.rate: case Action.rateReceived: case Action.cooperativeCancelInitiatedByYou: diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart index 98bcc80f..ca772ccb 100644 --- a/lib/features/order/notfiers/add_order_notifier.dart +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -35,9 +35,10 @@ class AddOrderNotifier extends AbstractOrderNotifier { } Future submitOrder(Order order) async { - final requestId = BigInt.parse(orderId.replaceAll('-', ''), radix: 16) - .toUnsigned(64) - .toInt(); + final requestId = BigInt.parse( + orderId.replaceAll('-', ''), + radix: 16, + ).toUnsigned(64).toInt(); final message = MostroMessage( action: Action.newOrder, diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 76eb043a..6d1d45bd 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/peer.dart'; import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; class OrderNotifier extends AbstractOrderNotifier { @@ -9,6 +10,8 @@ class OrderNotifier extends AbstractOrderNotifier { Future sync() async { state = await mostroService.getOrderById(orderId) ?? state; + state.payload is Order ? order = state.getPayload() : null; + state.payload is Peer ? peer = state.getPayload() : null; } Future resubscribe() async { diff --git a/lib/features/settings/settings.dart b/lib/features/settings/settings.dart index 1fed1d17..1bd5d40c 100644 --- a/lib/features/settings/settings.dart +++ b/lib/features/settings/settings.dart @@ -4,17 +4,19 @@ class Settings { final String mostroPublicKey; final String? defaultFiatCode; - Settings( - {required this.relays, - required this.fullPrivacyMode, - required this.mostroPublicKey, - this.defaultFiatCode}); + Settings({ + required this.relays, + required this.fullPrivacyMode, + required this.mostroPublicKey, + this.defaultFiatCode, + }); - Settings copyWith( - {List? relays, - bool? privacyModeSetting, - String? mostroInstance, - String? defaultFiatCode}) { + Settings copyWith({ + List? relays, + bool? privacyModeSetting, + String? mostroInstance, + String? defaultFiatCode, + }) { return Settings( relays: relays ?? this.relays, fullPrivacyMode: privacyModeSetting ?? fullPrivacyMode, @@ -34,7 +36,7 @@ class Settings { return Settings( relays: (json['relays'] as List?)?.cast() ?? [], fullPrivacyMode: json['fullPrivacyMode'] as bool, - mostroPublicKey: json['mostroPublicKey'] ?? json['mostroInstance'], + mostroPublicKey: json['mostroPublicKey'], defaultFiatCode: json['defaultFiatCode'], ); } diff --git a/lib/features/settings/settings_notifier.dart b/lib/features/settings/settings_notifier.dart index e055f95c..a12fa5b7 100644 --- a/lib/features/settings/settings_notifier.dart +++ b/lib/features/settings/settings_notifier.dart @@ -9,9 +9,7 @@ class SettingsNotifier extends StateNotifier { final SharedPreferencesAsync _prefs; static final String _storageKey = SharedPreferencesKeys.appSettings.value; - SettingsNotifier(this._prefs) : super(_defaultSettings()) { - //init(); - } + SettingsNotifier(this._prefs) : super(_defaultSettings()); static Settings _defaultSettings() { return Settings( @@ -40,17 +38,17 @@ class SettingsNotifier extends StateNotifier { await _saveToPrefs(); } - Future updatePrivacyModeSetting(bool newValue) async { + Future updatePrivacyMode(bool newValue) async { state = state.copyWith(privacyModeSetting: newValue); await _saveToPrefs(); } - Future updateMostroInstanceSetting(String newValue) async { + Future updateMostroInstance(String newValue) async { state = state.copyWith(mostroInstance: newValue); await _saveToPrefs(); } - Future updateDefaultFiatCodeSetting(String newValue) async { + Future updateDefaultFiatCode(String newValue) async { state = state.copyWith(defaultFiatCode: newValue); await _saveToPrefs(); } diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 6a35dbfc..0805ed0e 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -56,7 +56,7 @@ class SettingsScreen extends ConsumerWidget { onChanged: (bool value) { ref .watch(settingsProvider.notifier) - .updatePrivacyModeSetting(value); + .updatePrivacyMode(value); }), const SizedBox(height: 8), CurrencyComboBox( @@ -64,7 +64,7 @@ class SettingsScreen extends ConsumerWidget { onSelected: (fiatCode) { ref .watch(settingsProvider.notifier) - .updateDefaultFiatCodeSetting(fiatCode); + .updateDefaultFiatCode(fiatCode); }, ), const SizedBox(height: 8), @@ -100,7 +100,7 @@ class SettingsScreen extends ConsumerWidget { style: const TextStyle(color: AppTheme.cream1), onChanged: (value) => ref .watch(settingsProvider.notifier) - .updateMostroInstanceSetting(value), + .updateMostroInstance(value), decoration: InputDecoration( border: InputBorder.none, labelText: 'Mostro Pubkey', diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index b3306b40..082bc3aa 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -7,13 +7,14 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; -import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/features/trades/widgets/mostro_message_detail_widget.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; @@ -65,7 +66,9 @@ class TradeDetailScreen extends ConsumerWidget { /// Builds a card showing the user is "selling/buying X sats for Y fiat" etc. Widget _buildSellerAmount(WidgetRef ref, NostrEvent order) { - final selling = order.orderType == OrderType.sell ? 'selling' : 'buying'; + final session = ref.watch(sessionProvider(order.orderId!)); + + final selling = session!.role == Role.seller ? 'selling' : 'buying'; final amountString = '${order.fiatAmount} ${order.currency} ${CurrencyUtils.getFlagFromCurrency(order.currency!)}'; @@ -180,22 +183,38 @@ class TradeDetailScreen extends ConsumerWidget { List _buildActionButtons( BuildContext context, WidgetRef ref, NostrEvent order) { final message = ref.watch(orderNotifierProvider(orderId)); + final session = ref.watch(sessionProvider(orderId)); // The finite-state-machine approach: decide based on the order.status. // Then refine if needed using the last action in `message.action`. switch (order.status) { case Status.pending: + return [ + _buildCloseButton(context), + _buildCancelButton(context, ref), + if (message.action == actions.Action.addInvoice) + _buildAddInvoiceButton(context), + ]; case Status.waitingPayment: // Usually, a pending or waitingPayment order can be canceled. // Possibly the user could do more if they’re the buyer vs. seller, // but for simplicity we show CANCEL only. return [ _buildCloseButton(context), - _buildCancelButton(ref), + _buildCancelButton(context, ref), ]; case Status.waitingBuyerInvoice: + return [ + _buildCloseButton(context), + if (message.action == actions.Action.addInvoice) + _buildAddInvoiceButton(context), + ]; case Status.settledHoldInvoice: + return [ + _buildCloseButton(context), + if (message.action == actions.Action.rate) _buildRateButton(context), + ]; case Status.active: return [ _buildCloseButton(context), @@ -205,18 +224,16 @@ class TradeDetailScreen extends ConsumerWidget { message.action != actions.Action.rate) _buildDisputeButton(ref), - // If the action is "addInvoice" => maybe show a button to push the invoice screen. + // If the action is "addInvoice" => show a button for the invoice screen. if (message.action == actions.Action.addInvoice) _buildAddInvoiceButton(context), + // If the order is waiting for buyer to confirm fiat was sent - if (message.action == actions.Action.holdInvoicePaymentAccepted) - _buildFiatSentButton(ref), + if (session!.role == Role.buyer) _buildFiatSentButton(ref), // If the user is the seller & the buyer is done => show release button - if (message.action == actions.Action.buyerTookOrder) - _buildReleaseButton(ref), + if (session.role == Role.seller) _buildReleaseButton(ref), // If the user is ready to rate - if (message.action == actions.Action.rate) - _buildRateButton(context), + if (message.action == actions.Action.rate) _buildRateButton(context), ]; case Status.fiatSent: @@ -224,6 +241,7 @@ class TradeDetailScreen extends ConsumerWidget { // or just close the screen and wait. return [ _buildCloseButton(context), + if (session!.role == Role.seller) _buildReleaseButton(ref), _buildDisputeButton(ref), ]; @@ -231,16 +249,16 @@ class TradeDetailScreen extends ConsumerWidget { return [ _buildCloseButton(context), if (message.action == actions.Action.cooperativeCancelInitiatedByPeer) - _buildCancelButton(ref), + _buildCancelButton(context, ref), ]; case Status.success: return [ _buildCloseButton(context), - _buildRateButton(context), + if (message.action != actions.Action.rateReceived) + _buildRateButton(context), ]; - // For these statuses, we usually just let the user close the screen. case Status.expired: case Status.dispute: case Status.completedByAdmin: @@ -300,11 +318,12 @@ class TradeDetailScreen extends ConsumerWidget { } /// CANCEL - Widget _buildCancelButton(WidgetRef ref) { + Widget _buildCancelButton(BuildContext context, WidgetRef ref) { final notifier = ref.read(orderNotifierProvider(orderId).notifier); return ElevatedButton( onPressed: () async { await notifier.cancelOrder(); + context.pop(); }, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.red1, diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index 237aae89..0387737f 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -5,6 +5,7 @@ import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/enums/cant_do_reason.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/peer.dart'; @@ -13,6 +14,7 @@ import 'package:mostro_mobile/features/order/providers/order_notifier_provider.d import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; class MostroMessageDetail extends ConsumerWidget { @@ -24,7 +26,7 @@ class MostroMessageDetail extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { // Retrieve the MostroMessage using the order's orderId final mostroMessage = ref.watch(orderNotifierProvider(order.orderId!)); - + final session = ref.watch(sessionProvider(order.orderId!)); // Map the action enum to the corresponding i10n string. String actionText; switch (mostroMessage.action) { @@ -90,8 +92,9 @@ class MostroMessageDetail extends ConsumerWidget { break; case actions.Action.fiatSentOk: final payload = mostroMessage.getPayload(); - actionText = S.of(context)!.fiatSentOkBuyer(payload!.publicKey); - //actionText = S.of(context)!.fiatSentOkSeller; + actionText = session!.role == Role.buyer + ? S.of(context)!.fiatSentOkBuyer(payload!.publicKey) + : S.of(context)!.fiatSentOkSeller(payload!.publicKey); break; case actions.Action.released: actionText = S.of(context)!.released('{seller_npub}'); @@ -100,10 +103,9 @@ class MostroMessageDetail extends ConsumerWidget { actionText = S.of(context)!.purchaseCompleted; break; case actions.Action.holdInvoicePaymentSettled: - final payload = mostroMessage.getPayload(); actionText = S .of(context)! - .holdInvoicePaymentSettled(payload!.buyerTradePubkey!); + .holdInvoicePaymentSettled('{buyer_npub}'); break; case actions.Action.rate: actionText = S.of(context)!.rate; diff --git a/lib/features/trades/widgets/trades_list_item.dart b/lib/features/trades/widgets/trades_list_item.dart index 407e89da..1a56b486 100644 --- a/lib/features/trades/widgets/trades_list_item.dart +++ b/lib/features/trades/widgets/trades_list_item.dart @@ -4,9 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/providers/time_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; @@ -33,7 +34,7 @@ class TradesListItem extends ConsumerWidget { children: [ _buildHeader(context), const SizedBox(height: 16), - _buildSessionDetails(context), + _buildSessionDetails(context, ref), const SizedBox(height: 8), ], ), @@ -56,10 +57,11 @@ class TradesListItem extends ConsumerWidget { ); } - Widget _buildSessionDetails(BuildContext context) { + Widget _buildSessionDetails(BuildContext context, WidgetRef ref) { + final session = ref.watch(sessionProvider(trade.orderId!)); return Row( children: [ - _getOrderOffering(context, trade), + _getOrderOffering(context, trade, session!.role), const SizedBox(width: 16), Expanded( flex: 3, @@ -69,8 +71,12 @@ class TradesListItem extends ConsumerWidget { ); } - Widget _getOrderOffering(BuildContext context, NostrEvent trade) { - String offering = trade.orderType == OrderType.buy ? 'Buying' : 'Selling'; + Widget _getOrderOffering( + BuildContext context, + NostrEvent trade, + Role? role, + ) { + String offering = role == Role.buyer ? 'Buying' : 'Selling'; String amountText = (trade.amount != null && trade.amount != '0') ? ' ${trade.amount!}' : ''; diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index c180ea7a..1a2f5a16 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -3,10 +3,12 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/amount.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; -import 'package:mostro_mobile/data/models/payload.dart'; +import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/payment_request.dart'; import 'package:mostro_mobile/data/models/rating_user.dart'; import 'package:mostro_mobile/data/models/session.dart'; @@ -18,12 +20,12 @@ import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; class MostroService { final NostrService _nostrService; - final SessionNotifier _sessionManager; + final SessionNotifier _sessionNotifier; final MostroStorage _messageStorage; final _logger = Logger(); Settings _settings; - MostroService(this._nostrService, this._sessionManager, this._settings, + MostroService(this._nostrService, this._sessionNotifier, this._settings, this._messageStorage); Future getOrderById(String orderId) async { @@ -37,8 +39,9 @@ class MostroService { ); final events = await _nostrService.fecthEvents(filter); List orders = []; + final eventsCopy = List.from(events); - for (final event in events) { + for (final event in eventsCopy) { final decryptedEvent = await event.unWrap( session.tradeKey.private, ); @@ -107,13 +110,13 @@ class MostroService { final msg = MostroMessage.fromJson(msgMap['order']); if (msg.action == actions.Action.canceled) { - await _sessionManager.deleteSession(session.keyIndex); + await _sessionNotifier.deleteSession(session.orderId!); return msg; } if (session.orderId == null && msg.id != null) { session.orderId = msg.id; - await _sessionManager.saveSession(session); + await _sessionNotifier.saveSession(session); } await _saveMessage(msg); return msg; @@ -134,7 +137,7 @@ class MostroService { } Session? getSessionByOrderId(String orderId) { - return _sessionManager.getSessionByOrderId(orderId); + return _sessionNotifier.getSessionByOrderId(orderId); } Future takeBuyOrder(String orderId, int? amount) async { @@ -222,50 +225,44 @@ class MostroService { Future submitRating(String orderId, int rating) async { await publishOrder(MostroMessage( - action: Action.rateUser, - id: orderId, - payload: RatingUser(userRating: rating))); + action: Action.rateUser, + id: orderId, + payload: RatingUser(userRating: rating), + )); } Future publishOrder(MostroMessage order) async { - final session = (order.id != null) - ? _sessionManager.getSessionByOrderId(order.id!) ?? - await _sessionManager.newSession(orderId: order.id) - : await _sessionManager.newSession(); - - String content; - if (!session.fullPrivacy) { - order.tradeIndex = session.keyIndex; - content = order.serialize(keyPair: session.tradeKey); - } else { - content = order.serialize(); - } - _logger.i('Publishing order: $content'); - final event = await createNIP59Event( - content, - _settings.mostroPublicKey, - session, + final session = await _getSession(order); + final event = await order.wrap( + tradeKey: session.tradeKey, + recipientPubKey: _settings.mostroPublicKey, + masterKey: session.fullPrivacy ? null : session.masterKey, + keyIndex: session.fullPrivacy ? null : session.keyIndex, ); await _nostrService.publishEvent(event); return session; } - Future createNIP59Event( - String content, String recipientPubKey, Session session) async { - final keySet = session.fullPrivacy ? session.tradeKey : session.masterKey; - - final encryptedContent = await _nostrService.createRumor( - session.tradeKey, keySet.private, recipientPubKey, content); - - final wrapperKeyPair = await _nostrService.generateKeyPair(); - - String sealedContent = await _nostrService.createSeal( - keySet, wrapperKeyPair.private, recipientPubKey, encryptedContent); - - final wrapEvent = await _nostrService.createWrap( - wrapperKeyPair, sealedContent, recipientPubKey); + Role? _getRole(MostroMessage order) { + final payload = order.getPayload(); + + return order.action == Action.newOrder + ? payload?.kind == OrderType.buy + ? Role.buyer + : Role.seller + : order.action == Action.takeBuy + ? Role.seller + : order.action == Action.takeSell + ? Role.buyer + : null; + } - return wrapEvent; + Future _getSession(MostroMessage order) async { + final role = _getRole(order); + return (order.id != null) + ? _sessionNotifier.getSessionByOrderId(order.id!) ?? + await _sessionNotifier.newSession(orderId: order.id, role: role) + : await _sessionNotifier.newSession(role: role); } void updateSettings(Settings settings) { diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index dc21ab35..be2bc3ad 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -1,49 +1,157 @@ +import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/data/repositories/session_manager.dart'; +import 'package:mostro_mobile/data/repositories/session_storage.dart'; +import 'package:mostro_mobile/features/key_manager/key_manager.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; class SessionNotifier extends StateNotifier> { - final SessionManager _manager; + // Dependencies + final Logger _logger = Logger(); + final KeyManager _keyManager; + final SessionStorage _storage; - SessionNotifier(this._manager) : super(_manager.sessions); + // Current settings + Settings _settings; - Future newSession({String? orderId}) async { - final session = await _manager.newSession(orderId: orderId); - state = _manager.sessions; - return session; + // In-memory session cache, keyed by `session.keyIndex`. + final Map _sessions = {}; + + // Cleanup / expiration logic + Timer? _cleanupTimer; + static const int sessionExpirationHours = 36; + static const int cleanupIntervalMinutes = 30; + static const int maxBatchSize = 100; + + /// Public getter to expose sessions (if needed) + List get sessions => _sessions.values.toList(); + + SessionNotifier( + this._keyManager, + this._storage, + this._settings, + ) : super([]) { + //_init(); + //_initializeCleanup(); } - Future reset() async { - await _manager.reset(); + /// Initialize by loading all sessions from DB into memory, then updating state. + Future init() async { + final allSessions = await _storage.getAllSessions(); + for (final session in allSessions) { + _sessions[session.orderId!] = session; + } + // Update the notifier state with fresh data. + state = sessions; } - void refresh() { - state = _manager.sessions; + /// Update the application settings if needed. + void updateSettings(Settings settings) { + _settings = settings.copyWith(); + // You might want to refresh or do something else if settings impact sessions. } - Future saveSession(Session session) async { - await _manager.saveSession(session); - state = _manager.sessions; + /// Creates a new session, storing it both in memory and in the database. + Future newSession({String? orderId, Role? role}) async { + final masterKey = _keyManager.masterKeyPair!; + final keyIndex = await _keyManager.getCurrentKeyIndex(); + final tradeKey = await _keyManager.deriveTradeKey(); + + final session = Session( + startTime: DateTime.now(), + masterKey: masterKey, + keyIndex: keyIndex, + tradeKey: tradeKey, + fullPrivacy: _settings.fullPrivacyMode, + orderId: orderId, + role: role, + ); + + // Cache it in memory + + if (orderId != null) { + _sessions[orderId] = session; + // Persist it to DB + await _storage.putSession(session); + state = sessions; + } else { + state = [...sessions, session]; + } + return session; } - Future deleteSession(int sessionId) async { - await _manager.deleteSession(sessionId); - state = _manager.sessions; + /// Updates a session in both memory and database. + Future saveSession(Session session) async { + _sessions[session.orderId!] = session; + await _storage.putSession(session); + state = sessions; } + /// Retrieve the first session whose `orderId` matches [orderId]. Session? getSessionByOrderId(String orderId) { - return _manager.getSessionByOrderId(orderId); + try { + return _sessions[orderId]; + } on StateError { + return null; + } } + /// Retrieve a session by its keyIndex (checks memory first, then DB). Future loadSession(int keyIndex) async { - final s = await _manager.loadSession(keyIndex); - state = _manager.sessions; - return s; + final sessions = await _storage.getAllSessions(); + return sessions.firstWhere((s) => s.keyIndex == keyIndex); + } + + /// Resets all stored sessions by clearing DB and memory. + Future reset() async { + await _storage.deleteAllItems(); + _sessions.clear(); + state = []; + } + + /// Deletes a session from memory and DB. + Future deleteSession(String sessionId) async { + _sessions.remove(sessionId); + await _storage.deleteSession(sessionId); + state = sessions; + } + + /// Removes sessions older than [sessionExpirationHours] from both DB and memory. + Future clearExpiredSessions() async { + try { + final removedIds = await _storage.deleteExpiredSessions( + sessionExpirationHours, + maxBatchSize, + ); + for (final id in removedIds) { + // If your underlying session keys are strings, + // you may have to parse them to int or store them as-is. + _sessions.remove(id); + } + state = sessions; + } catch (e) { + _logger.e('Error during session cleanup: $e'); + } + } + + /// Set up an initial cleanup run and a periodic timer. + void _initializeCleanup() { + _cleanupTimer?.cancel(); + // Immediately do a cleanup pass + clearExpiredSessions(); + // Schedule periodic cleanup + _cleanupTimer = Timer.periodic( + const Duration(minutes: cleanupIntervalMinutes), + (_) => clearExpiredSessions(), + ); } + /// Dispose resources (like timers) when no longer needed. @override void dispose() { - _manager.dispose(); + _cleanupTimer?.cancel(); super.dispose(); } } diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 146b8f76..22985297 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -16,12 +16,9 @@ final appInitializerProvider = FutureProvider((ref) async { await nostrService.init(); final keyManager = ref.read(keyManagerProvider); - bool hasMaster = await keyManager.hasMasterKey(); - if (!hasMaster) { - await keyManager.generateAndStoreMasterKey(); - } + await keyManager.init(); - final sessionManager = ref.read(sessionManagerProvider); + final sessionManager = ref.read(sessionNotifierProvider.notifier); await sessionManager.init(); final mostroService = ref.read(mostroServiceProvider); @@ -32,8 +29,8 @@ final appInitializerProvider = FutureProvider((ref) async { }); for (final session in sessionManager.sessions) { - await mostroService.sync(session); if (session.orderId != null) { + await mostroService.sync(session); final order = ref.watch(orderNotifierProvider(session.orderId!).notifier); order.resubscribe(); } diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart index 207718c7..1ccdfab0 100644 --- a/lib/shared/providers/mostro_storage_provider.dart +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -4,5 +4,5 @@ import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; final mostroStorageProvider = Provider((ref) { final mostroDatabase = ref.watch(mostroDatabaseProvider); - return MostroStorage(mostroDatabase); + return MostroStorage(db: mostroDatabase); }); diff --git a/lib/shared/providers/session_manager_provider.dart b/lib/shared/providers/session_manager_provider.dart index c2d39f3d..da7bd83a 100644 --- a/lib/shared/providers/session_manager_provider.dart +++ b/lib/shared/providers/session_manager_provider.dart @@ -1,32 +1,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/data/repositories/session_manager.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; import 'package:mostro_mobile/shared/providers/session_storage_provider.dart'; -final sessionManagerProvider = Provider((ref) { + +final sessionNotifierProvider = + StateNotifierProvider>((ref) { final keyManager = ref.read(keyManagerProvider); final sessionStorage = ref.read(sessionStorageProvider); final settings = ref.read(settingsProvider); - - final sessionManager = SessionManager( + return SessionNotifier( keyManager, sessionStorage, settings.copyWith(), ); - - return sessionManager; }); -final sessionsProvider = Provider>((ref) { - final sessionManager = ref.watch(sessionManagerProvider); - return sessionManager.sessions; -}); - -final sessionNotifierProvider = - StateNotifierProvider>((ref) { - final manager = ref.watch(sessionManagerProvider); - return SessionNotifier(manager); +final sessionProvider = StateProvider.family((ref, id) { + final notifier = ref.watch(sessionNotifierProvider.notifier); + return notifier.getSessionByOrderId(id); }); diff --git a/lib/shared/providers/session_storage_provider.dart b/lib/shared/providers/session_storage_provider.dart index 19166c29..0b3c26dd 100644 --- a/lib/shared/providers/session_storage_provider.dart +++ b/lib/shared/providers/session_storage_provider.dart @@ -6,5 +6,5 @@ import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; final sessionStorageProvider = Provider((ref) { final keyManager = ref.read(keyManagerProvider); final database = ref.read(mostroDatabaseProvider); - return SessionStorage(database, keyManager); + return SessionStorage(db: database, keyManager); }); From 2d905305430eee00247c3d6c736a0f8f4f48eb31 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Tue, 25 Mar 2025 12:26:26 +1000 Subject: [PATCH 084/149] Pay Invoice button added as per Add a Show Invoice button to the seller in status Waiting-payment #49 --- .../trades/screens/trade_detail_screen.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 082bc3aa..6334df6f 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -202,6 +202,7 @@ class TradeDetailScreen extends ConsumerWidget { return [ _buildCloseButton(context), _buildCancelButton(context, ref), + _buildPayInvoiceButton(context), ]; case Status.waitingBuyerInvoice: @@ -317,6 +318,19 @@ class TradeDetailScreen extends ConsumerWidget { ); } + /// ADD INVOICE + Widget _buildPayInvoiceButton(BuildContext context) { + return ElevatedButton( + onPressed: () async { + context.push('/pay_invoice/$orderId'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('ADD INVOICE'), + ); + } + /// CANCEL Widget _buildCancelButton(BuildContext context, WidgetRef ref) { final notifier = ref.read(orderNotifierProvider(orderId).notifier); From 61a4ac0df60b5709a755f1b9342bcedfb6d9cca4 Mon Sep 17 00:00:00 2001 From: Chris Daley Date: Tue, 25 Mar 2025 12:30:38 +1000 Subject: [PATCH 085/149] Cancel button should appear for relevant order states Button cancel in all screens in active status #42 --- lib/features/trades/screens/trade_detail_screen.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 6334df6f..97a705f0 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -208,6 +208,7 @@ class TradeDetailScreen extends ConsumerWidget { case Status.waitingBuyerInvoice: return [ _buildCloseButton(context), + _buildCancelButton(context, ref), if (message.action == actions.Action.addInvoice) _buildAddInvoiceButton(context), ]; @@ -219,6 +220,7 @@ class TradeDetailScreen extends ConsumerWidget { case Status.active: return [ _buildCloseButton(context), + _buildCancelButton(context, ref), // If user has not opened a dispute already if (message.action != actions.Action.disputeInitiatedByYou && message.action != actions.Action.disputeInitiatedByPeer && @@ -259,14 +261,16 @@ class TradeDetailScreen extends ConsumerWidget { if (message.action != actions.Action.rateReceived) _buildRateButton(context), ]; - + case Status.inProgress: + return [ + _buildCancelButton(context, ref), + ]; case Status.expired: case Status.dispute: case Status.completedByAdmin: case Status.canceledByAdmin: case Status.settledByAdmin: case Status.canceled: - case Status.inProgress: return [ _buildCloseButton(context), ]; From 146c549a2b5853368f69813c77fa244b9f6f8d27 Mon Sep 17 00:00:00 2001 From: Biz Date: Fri, 28 Mar 2025 23:06:15 +1000 Subject: [PATCH 086/149] First cut of Mostro Chat --- lib/core/app_routes.dart | 7 + lib/data/models/chat_room.dart | 14 +- lib/data/models/nostr_event.dart | 64 +++++- lib/data/models/session.dart | 28 ++- lib/data/repositories/session_storage.dart | 5 +- .../notifiers/chat_room_notifier.dart | 57 +++++- .../notifiers/chat_rooms_notifier.dart | 17 +- .../providers/chat_room_providers.dart | 25 ++- lib/features/messages/screens/chat_room.dart | 121 ----------- .../messages/screens/chat_room_screen.dart | 189 ++++++++++++++++++ .../messages/screens/chat_rooms_list.dart | 121 ++++++----- lib/features/mostro/mostro_screen.dart | 3 + .../notfiers/abstract_order_notifier.dart | 24 ++- .../order/notfiers/order_notifier.dart | 3 - lib/features/order/widgets/order_app_bar.dart | 5 +- .../trades/screens/trade_detail_screen.dart | 40 ++-- .../widgets/mostro_message_detail_widget.dart | 14 +- lib/shared/providers/app_init_provider.dart | 12 +- lib/shared/providers/avatar_provider.dart | 85 ++++++++ .../providers/legible_hande_provider.dart | 139 +++++++++++++ lib/shared/utils/nostr_utils.dart | 40 ++-- lib/shared/widgets/bottom_nav_bar.dart | 17 +- pubspec.lock | 8 + pubspec.yaml | 1 + 24 files changed, 782 insertions(+), 257 deletions(-) delete mode 100644 lib/features/messages/screens/chat_room.dart create mode 100644 lib/features/messages/screens/chat_room_screen.dart create mode 100644 lib/shared/providers/avatar_provider.dart create mode 100644 lib/shared/providers/legible_hande_provider.dart diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index 9b4c915d..3387b406 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/features/messages/screens/chat_room_screen.dart'; import 'package:mostro_mobile/features/order/screens/add_order_screen.dart'; import 'package:mostro_mobile/features/order/screens/order_confirmation_screen.dart'; import 'package:mostro_mobile/features/auth/screens/welcome_screen.dart'; @@ -55,6 +56,12 @@ final goRouter = GoRouter( path: '/chat_list', builder: (context, state) => const ChatRoomsScreen(), ), + GoRoute( + path: '/chat_room/:orderId', + builder: (context, state) => ChatRoomScreen( + orderId: state.pathParameters['orderId']!, + ), + ), GoRoute( path: '/register', builder: (context, state) => const RegisterScreen(), diff --git a/lib/data/models/chat_room.dart b/lib/data/models/chat_room.dart index fc28033a..a2f14eee 100644 --- a/lib/data/models/chat_room.dart +++ b/lib/data/models/chat_room.dart @@ -1,9 +1,19 @@ import 'package:dart_nostr/nostr/model/event/event.dart'; class ChatRoom { - final List messages = []; + final String orderId; + final List messages; - void sortMessages() { + ChatRoom({required this.orderId, required this.messages}) { messages.sort((a, b) => a.createdAt!.compareTo(b.createdAt!)); } + + ChatRoom copy({ + List? messages, + }) { + return ChatRoom( + orderId: orderId, + messages: messages ?? this.messages, + ); + } } diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index f3d08c6c..05a4dea5 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/range_amount.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; @@ -70,5 +72,65 @@ extension NostrEventExtensions on NostrEvent { privateKey, ); } - + + Future mostroUnWrap(NostrKeyPairs receiver) async { + if (kind != 1059) { + throw ArgumentError('Wrong kind: $kind'); + } + + if (content == null || content!.isEmpty) { + throw ArgumentError('Event content is empty'); + } + + final decryptedContent = await NostrUtils.decryptNIP44( + content!, + receiver.private, + pubkey, + ); + + final rumorEvent = NostrEvent.deserialized( + '["EVENT", "", $decryptedContent]', + ); + if (rumorEvent.kind != 1) { + throw Exception('Not a Mostro DM: ${rumorEvent.toString()}'); + } + return rumorEvent; + } + + Future mostroWrap(NostrKeyPairs sharedKey) async { + if (kind != 1) { + throw ArgumentError('Wrong kind: $kind'); + } + + if (content == null || content!.isEmpty) { + throw ArgumentError('Event content is empty'); + } + + final wrapperKeyPair = NostrUtils.generateKeyPair(); + + final encryptedContent = await NostrUtils.encryptNIP44( + jsonEncode(toMap()), + wrapperKeyPair.private, + sharedKey.public, + ); + + final event = NostrUtils.createWrap( + wrapperKeyPair, + encryptedContent, + sharedKey.public, + ); + return event; + } + + NostrEvent copy() { + return NostrEvent( + content: content, + createdAt: createdAt, + id: id, + kind: kind, + pubkey: pubkey, + sig: sig, + tags: tags, + ); + } } diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index 89754d70..c0165155 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -1,6 +1,7 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/peer.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; /// Represents a User session /// @@ -13,7 +14,8 @@ class Session { final DateTime startTime; String? orderId; Role? role; - Peer? peer; + Peer? _peer; + NostrKeyPairs? _sharedKey; Session({ required this.masterKey, @@ -23,8 +25,16 @@ class Session { required this.startTime, this.orderId, this.role, - this.peer, - }); + Peer? peer, + }) { + _peer = peer; + if (peer != null) { + _sharedKey = NostrUtils.computeSharedKey( + tradeKey.private, + peer.publicKey, + ); + } + } Map toJson() => { 'trade_key': tradeKey.public, @@ -48,4 +58,16 @@ class Session { peer: json['peer'] != null ? Peer(publicKey: json['peer']) : null, ); } + + NostrKeyPairs? get sharedKey => _sharedKey; + + Peer? get peer => _peer; + + set peer(Peer? newPeer) { + _peer = newPeer; + _sharedKey = NostrUtils.computeSharedKey( + tradeKey.private, + newPeer!.publicKey, + ); + } } diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index ec93123a..59a34dac 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -1,4 +1,3 @@ -import 'package:dart_nostr/nostr/core/key_pairs.dart'; import 'package:mostro_mobile/data/repositories/base_storage.dart'; import 'package:sembast/sembast.dart'; import 'package:mostro_mobile/data/models/session.dart'; @@ -18,13 +17,11 @@ class SessionStorage extends BaseStorage { @override Map toDbMap(Session session) { - // Convert Session -> JSON return session.toJson(); } @override Session fromDbMap(String key, Map jsonMap) { - // Re-derive or do any specialized logic return _decodeSession(key, jsonMap); } @@ -42,7 +39,7 @@ class SessionStorage extends BaseStorage { if (tradeKeyPair.public != tradeKey) { throw ArgumentError('Trade key does not match derived key'); } - clone['trade_key'] = NostrKeyPairs(private: tradeKey); + clone['trade_key'] = tradeKeyPair; clone['master_key'] = masterKey; return Session.fromJson(clone); diff --git a/lib/features/messages/notifiers/chat_room_notifier.dart b/lib/features/messages/notifiers/chat_room_notifier.dart index 87393960..50b171a4 100644 --- a/lib/features/messages/notifiers/chat_room_notifier.dart +++ b/lib/features/messages/notifiers/chat_room_notifier.dart @@ -1,11 +1,62 @@ +import 'dart:async'; + import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; class ChatRoomNotifier extends StateNotifier { - ChatRoomNotifier(super.state); + final _logger = Logger(); + final String orderId; + final Ref ref; + late StreamSubscription subscription; + + ChatRoomNotifier(super.state, this.orderId, this.ref); + + void subscribe() { + final session = ref.read(sessionProvider(orderId)); + final filter = NostrFilter( + kinds: [1059], + p: [session!.sharedKey!.public], + ); + subscription = + ref.read(nostrServiceProvider).subscribeToEvents(filter).listen( + (event) async { + try { + final chat = await event.mostroUnWrap(session.sharedKey!); + if (!state.messages.contains(chat)) { + state = state.copy( + messages: [ + ...state.messages, + chat, + ], + ); + } + } catch (e) { + _logger.e(e); + } + }, + ); + } + + Future sendMessage(String text) async { + final session = ref.read(sessionProvider(orderId)); + final event = NostrEvent.fromPartialData( + keyPairs: session!.tradeKey, + content: text, + kind: 1, + ); - Future subscribe(Stream stream) async {} + final wrappedEvent = await event.mostroWrap(session.sharedKey!); + ref.read(nostrServiceProvider).publishEvent(wrappedEvent); + } - void sendMessage(String text) {} + @override + void dispose() { + subscription.cancel(); + super.dispose(); + } } diff --git a/lib/features/messages/notifiers/chat_rooms_notifier.dart b/lib/features/messages/notifiers/chat_rooms_notifier.dart index 26a06285..c62eebe6 100644 --- a/lib/features/messages/notifiers/chat_rooms_notifier.dart +++ b/lib/features/messages/notifiers/chat_rooms_notifier.dart @@ -1,15 +1,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; +import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; class ChatRoomsNotifier extends StateNotifier> { - ChatRoomsNotifier() : super(const []) { + final SessionNotifier sessionNotifier; + final Ref ref; + final _logger = Logger(); + + ChatRoomsNotifier(this.ref, this.sessionNotifier) : super(const []) { loadChats(); } Future loadChats() async { + final sessions = ref.watch(sessionNotifierProvider.notifier).sessions; try { - + state = sessions.where((s) => s.peer != null).map((s) { + final chat = ref.read(chatRoomsProvider(s.orderId!)); + return chat; + }).toList(); } catch (e) { + _logger.e(e); } } } diff --git a/lib/features/messages/providers/chat_room_providers.dart b/lib/features/messages/providers/chat_room_providers.dart index 5a53e8d2..d9adbbfc 100644 --- a/lib/features/messages/providers/chat_room_providers.dart +++ b/lib/features/messages/providers/chat_room_providers.dart @@ -2,18 +2,25 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/features/messages/notifiers/chat_room_notifier.dart'; import 'package:mostro_mobile/features/messages/notifiers/chat_rooms_notifier.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; -final messagesListNotifierProvider = +final chatRoomsNotifierProvider = StateNotifierProvider>( - (ref) => ChatRoomsNotifier(), + (ref) { + final sessionNotifier = ref.watch(sessionNotifierProvider.notifier); + return ChatRoomsNotifier(ref, sessionNotifier); + }, ); -final messagesDetailNotifierProvider = StateNotifierProvider.family< - ChatRoomNotifier, ChatRoom, String>((ref, chatId) { - return ChatRoomNotifier(ChatRoom()); -}); - final chatRoomsProvider = - StateNotifierProvider>((ref) { - return ChatRoomsNotifier(); + StateNotifierProvider.family( + (ref, chatId) { + return ChatRoomNotifier( + ChatRoom( + orderId: chatId, + messages: [], + ), + chatId, + ref, + ); }); diff --git a/lib/features/messages/screens/chat_room.dart b/lib/features/messages/screens/chat_room.dart deleted file mode 100644 index d054cea5..00000000 --- a/lib/features/messages/screens/chat_room.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/chat_room.dart'; -import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; -import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; - -class ChatRoomScreen extends ConsumerStatefulWidget { - final String chatId; - - const ChatRoomScreen({super.key, required this.chatId}); - - @override - ConsumerState createState() => _MessagesDetailScreenState(); -} - -class _MessagesDetailScreenState extends ConsumerState { - final TextEditingController _textController = TextEditingController(); - - @override - Widget build(BuildContext context) { - final chatDetailState = - ref.watch(messagesDetailNotifierProvider(widget.chatId)); - - return Scaffold( - backgroundColor: const Color(0xFF1D212C), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - title: const Text('JACK FOOTSEY'), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/'), - ), - ), - body: _buildBody(chatDetailState), - bottomNavigationBar: const BottomNavBar(), - ); - } - - Widget _buildBody(ChatRoom state) { - return Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: state.messages.length, - itemBuilder: (context, index) { - final message = state.messages[index]; - return _buildMessageBubble(message); - }, - ), - ), - _buildMessageInput(), - ], - ); - } - - Widget _buildMessageBubble(NostrEvent message) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - alignment: message.pubkey == 'Mostro' - ? Alignment.centerLeft - : Alignment.centerRight, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: message.pubkey == 'Mostro' - ? const Color(0xFF303544) - : const Color(0xFF8CC541), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - message.content!, - style: const TextStyle(color: AppTheme.cream1), - ), - ), - ); - } - - Widget _buildMessageInput() { - return Container( - padding: const EdgeInsets.all(8), - color: const Color(0xFF303544), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _textController, - style: const TextStyle(color: AppTheme.cream1), - decoration: InputDecoration( - hintText: 'Type a message...', - hintStyle: const TextStyle(color: Colors.grey), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: const Color(0xFF1D212C), - ), - ), - ), - IconButton( - icon: const Icon(Icons.send, color: Color(0xFF8CC541)), - onPressed: () { - final text = _textController.text.trim(); - if (text.isNotEmpty) { - ref - .read( - messagesDetailNotifierProvider(widget.chatId).notifier) - .sendMessage(text); - _textController.clear(); - } - }, - ), - ], - ), - ); - } -} diff --git a/lib/features/messages/screens/chat_room_screen.dart b/lib/features/messages/screens/chat_room_screen.dart new file mode 100644 index 00000000..0d4aca3d --- /dev/null +++ b/lib/features/messages/screens/chat_room_screen.dart @@ -0,0 +1,189 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/data/models/chat_room.dart'; +import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; +import 'package:mostro_mobile/shared/providers/legible_hande_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; + +class ChatRoomScreen extends ConsumerStatefulWidget { + final String orderId; + + const ChatRoomScreen({super.key, required this.orderId}); + + @override + ConsumerState createState() => _MessagesDetailScreenState(); +} + +class _MessagesDetailScreenState extends ConsumerState { + final TextEditingController _textController = TextEditingController(); + + @override + Widget build(BuildContext context) { + final chatDetailState = ref.watch(chatRoomsProvider(widget.orderId)); + final session = ref.read(sessionProvider(widget.orderId)); + final peer = session!.peer!.publicKey; + + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + 'BACK', + style: TextStyle( + color: AppTheme.cream1, + fontFamily: GoogleFonts.robotoCondensed().fontFamily, + ), + ), + leading: IconButton( + icon: const HeroIcon( + HeroIcons.arrowLeft, + color: AppTheme.cream1, + ), + onPressed: () => context.pop(), + ), + ), + body: RefreshIndicator( + onRefresh: () async {}, + child: Container( + margin: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: AppTheme.dark2, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + const SizedBox(height: 12.0), + Text('Order: ${widget.orderId}'), + _buildMessageHeader(peer, session), + _buildBody(chatDetailState, peer), + _buildMessageInput(), + const SizedBox(height: 12.0), + ], + ), + ), + ), + ); + } + + Widget _buildBody(ChatRoom state, String peer) { + return Expanded( + child: Container( + padding: const EdgeInsets.all(12), + child: ListView.builder( + itemCount: state.messages.length, + itemBuilder: (context, index) { + final message = state.messages[index]; + return _buildMessageBubble(message, peer); + }, + ), + ), + ); + } + + Widget _buildMessageBubble(NostrEvent message, String peer) { + final peerBubble = pickNymColor(peer); + return Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + alignment: + message.pubkey == peer ? Alignment.centerLeft : Alignment.centerRight, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: message.pubkey == peer ? peerBubble : const Color(0xFF8CC541), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + message.content!, + style: const TextStyle(color: AppTheme.cream1), + ), + ), + ); + } + + Widget _buildMessageInput() { + return Container( + padding: const EdgeInsets.fromLTRB(24, 0, 12, 18), + color: const Color(0xFF303544), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + style: const TextStyle(color: AppTheme.cream1), + decoration: InputDecoration( + hintText: 'Type a message...', + hintStyle: const TextStyle(color: Colors.grey), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: const Color(0xFF1D212C), + ), + ), + ), + IconButton( + icon: const Icon(Icons.send, color: Color(0xFF8CC541)), + onPressed: () { + final text = _textController.text.trim(); + if (text.isNotEmpty) { + ref + .read(chatRoomsProvider(widget.orderId).notifier) + .sendMessage(text); + _textController.clear(); + } + }, + ), + ], + ), + ); + } + + Widget _buildMessageHeader(String peerPubkey, Session session) { + final handle = ref.read(nickNameProvider(peerPubkey)); + final you = ref.read(nickNameProvider(session.tradeKey.public)); + final sharedKey = session.sharedKey?.public; + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFF1D212C), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + NymAvatar(pubkeyHex: peerPubkey), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'You are chatting with $handle', + style: const TextStyle( + color: AppTheme.cream1, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text('Your handle: $you'), + Text('Your shared key: $sharedKey'), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/messages/screens/chat_rooms_list.dart b/lib/features/messages/screens/chat_rooms_list.dart index d3270713..5afc9652 100644 --- a/lib/features/messages/screens/chat_rooms_list.dart +++ b/lib/features/messages/screens/chat_rooms_list.dart @@ -1,9 +1,13 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; +import 'package:mostro_mobile/shared/providers/legible_hande_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; @@ -13,10 +17,10 @@ class ChatRoomsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final chatListState = ref.watch(messagesListNotifierProvider); + final chatListState = ref.watch(chatRoomsNotifierProvider); return Scaffold( - backgroundColor: const Color(0xFF1D212C), + backgroundColor: AppTheme.dark1, appBar: const MostroAppBar(), drawer: const MostroAppDrawer(), body: Container( @@ -49,85 +53,74 @@ class ChatRoomsScreen extends ConsumerWidget { return Center( child: Text( 'No messages available', - style: AppTheme.theme.textTheme.displaySmall, )); } return ListView.builder( itemCount: state.length, itemBuilder: (context, index) { - return ChatListItem(chat: state[index].messages.first); + return ChatListItem( + orderId: state[index].orderId, + ); }, ); } } -class ChatListItem extends StatelessWidget { - final NostrEvent chat; +class ChatListItem extends ConsumerWidget { + final String orderId; - const ChatListItem({super.key, required this.chat}); + const ChatListItem({super.key, required this.orderId}); @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - decoration: BoxDecoration( - color: const Color(0xFF1D212C), - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - CircleAvatar( - backgroundColor: Colors.grey, - child: Text( - chat.pubkey.isNotEmpty ? chat.pubkey[0] : '?', - style: const TextStyle(color: AppTheme.cream1), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - chat.pubkey, - style: const TextStyle( - color: AppTheme.cream1, - fontWeight: FontWeight.bold, + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(sessionProvider(orderId)); + final pubkey = session!.peer!.publicKey; + final handle = ref.read(nickNameProvider(pubkey)); + return GestureDetector( + onTap: () { + context.push('/chat_room/$orderId'); + }, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFF1D212C), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + NymAvatar(pubkeyHex: pubkey), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + handle, + style: const TextStyle( + color: AppTheme.cream1, + fontWeight: FontWeight.bold, + ), ), - ), - Text( - chat.createdAt!.toIso8601String(), - style: const TextStyle(color: Colors.grey), - ), - ], - ), - const SizedBox(height: 4), - Text( - chat.content!, - style: const TextStyle(color: Colors.grey), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - if (chat.isVerified()) - Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Color(0xFF8CC541), - shape: BoxShape.circle, + ], + ), + ], ), ), - ], + ], + ), ), ), ); } + + String formatDateTime(DateTime dt) { + final dateFormatter = DateFormat('MMM dd HH:mm:ss'); + final formattedDate = dateFormatter.format(dt); + return formattedDate; + } } diff --git a/lib/features/mostro/mostro_screen.dart b/lib/features/mostro/mostro_screen.dart index 0171ad26..40f1f42b 100644 --- a/lib/features/mostro/mostro_screen.dart +++ b/lib/features/mostro/mostro_screen.dart @@ -17,8 +17,11 @@ class MostroScreen extends ConsumerWidget { return nostrEvent == null ? Scaffold( + appBar: const MostroAppBar(), + drawer: const MostroAppDrawer(), backgroundColor: AppTheme.dark1, body: const Center(child: CircularProgressIndicator()), + bottomNavigationBar: const BottomNavBar(), ) : Scaffold( backgroundColor: AppTheme.dark1, diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 43f1baaf..62eee03a 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -8,11 +8,13 @@ import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/peer.dart'; +import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; +import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; class AbstractOrderNotifier extends StateNotifier { final MostroService mostroService; @@ -20,8 +22,6 @@ class AbstractOrderNotifier extends StateNotifier { final String orderId; StreamSubscription? orderSubscription; final logger = Logger(); - Order? order; - Peer? peer; AbstractOrderNotifier( this.mostroService, @@ -37,8 +37,6 @@ class AbstractOrderNotifier extends StateNotifier { return; } state = event; - event.payload is Order ? order = event.getPayload() : null; - event.payload is Peer ? peer = event.getPayload() : null; handleOrderUpdate(); }); } catch (e) { @@ -99,6 +97,14 @@ class AbstractOrderNotifier extends StateNotifier { 'fiat_amount': order?.fiatAmount, 'payment_method': order?.paymentMethod, }); + // add seller tradekey to session + // open chat + final sessionProvider = ref.read(sessionNotifierProvider.notifier); + final session = sessionProvider.getSessionByOrderId(orderId); + session?.peer = Peer(publicKey: order!.buyerTradePubkey!); + sessionProvider.saveSession(session!); + final chat = ref.read(chatRoomsProvider(orderId).notifier); + chat.subscribe(); break; case Action.canceled: navProvider.go('/'); @@ -113,6 +119,14 @@ class AbstractOrderNotifier extends StateNotifier { 'fiat_amount': order?.fiatAmount, 'payment_method': order?.paymentMethod, }); + // add seller tradekey to session + // open chat + final sessionProvider = ref.read(sessionNotifierProvider.notifier); + final session = sessionProvider.getSessionByOrderId(orderId); + session?.peer = Peer(publicKey: order!.sellerTradePubkey!); + sessionProvider.saveSession(session!); + final chat = ref.read(chatRoomsProvider(orderId).notifier); + chat.subscribe(); break; case Action.fiatSentOk: final peer = state.getPayload(); @@ -122,7 +136,7 @@ class AbstractOrderNotifier extends StateNotifier { break; case Action.holdInvoicePaymentSettled: notifProvider.showInformation(state.action, values: { - 'buyer_npub': order?.buyerTradePubkey, + 'buyer_npub': 'buyerTradePubkey', }); break; case Action.rate: diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 6d1d45bd..76eb043a 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/data/models/peer.dart'; import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; class OrderNotifier extends AbstractOrderNotifier { @@ -10,8 +9,6 @@ class OrderNotifier extends AbstractOrderNotifier { Future sync() async { state = await mostroService.getOrderById(orderId) ?? state; - state.payload is Order ? order = state.getPayload() : null; - state.payload is Peer ? peer = state.getPayload() : null; } Future resubscribe() async { diff --git a/lib/features/order/widgets/order_app_bar.dart b/lib/features/order/widgets/order_app_bar.dart index 457c00f1..8f997d2d 100644 --- a/lib/features/order/widgets/order_app_bar.dart +++ b/lib/features/order/widgets/order_app_bar.dart @@ -15,7 +15,10 @@ class OrderAppBar extends StatelessWidget implements PreferredSizeWidget { backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( - icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), + icon: const HeroIcon( + HeroIcons.arrowLeft, + color: AppTheme.cream1, + ), onPressed: () => context.go('/'), ), title: Text( diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 97a705f0..479e94f3 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -54,8 +54,10 @@ class TradeDetailScreen extends ConsumerWidget { const SizedBox(height: 24), _buildCountDownTime(order.expirationDate), const SizedBox(height: 36), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, children: _buildActionButtons(context, ref, order), ), ], @@ -190,24 +192,21 @@ class TradeDetailScreen extends ConsumerWidget { switch (order.status) { case Status.pending: return [ - _buildCloseButton(context), + //_buildCloseButton(context), _buildCancelButton(context, ref), if (message.action == actions.Action.addInvoice) _buildAddInvoiceButton(context), ]; case Status.waitingPayment: - // Usually, a pending or waitingPayment order can be canceled. - // Possibly the user could do more if they’re the buyer vs. seller, - // but for simplicity we show CANCEL only. return [ - _buildCloseButton(context), + //_buildCloseButton(context), _buildCancelButton(context, ref), _buildPayInvoiceButton(context), ]; case Status.waitingBuyerInvoice: return [ - _buildCloseButton(context), + //_buildCloseButton(context), _buildCancelButton(context, ref), if (message.action == actions.Action.addInvoice) _buildAddInvoiceButton(context), @@ -219,18 +218,17 @@ class TradeDetailScreen extends ConsumerWidget { ]; case Status.active: return [ - _buildCloseButton(context), + //_buildCloseButton(context), _buildCancelButton(context, ref), + _buildContactButton(context), // If user has not opened a dispute already if (message.action != actions.Action.disputeInitiatedByYou && message.action != actions.Action.disputeInitiatedByPeer && message.action != actions.Action.rate) _buildDisputeButton(ref), - // If the action is "addInvoice" => show a button for the invoice screen. if (message.action == actions.Action.addInvoice) _buildAddInvoiceButton(context), - // If the order is waiting for buyer to confirm fiat was sent if (session!.role == Role.buyer) _buildFiatSentButton(ref), // If the user is the seller & the buyer is done => show release button @@ -243,21 +241,21 @@ class TradeDetailScreen extends ConsumerWidget { // Usually the user can open dispute if the other side doesn't confirm, // or just close the screen and wait. return [ - _buildCloseButton(context), + //_buildCloseButton(context), if (session!.role == Role.seller) _buildReleaseButton(ref), _buildDisputeButton(ref), ]; case Status.cooperativelyCanceled: return [ - _buildCloseButton(context), + //_buildCloseButton(context), if (message.action == actions.Action.cooperativeCancelInitiatedByPeer) _buildCancelButton(context, ref), ]; case Status.success: return [ - _buildCloseButton(context), + //_buildCloseButton(context), if (message.action != actions.Action.rateReceived) _buildRateButton(context), ]; @@ -277,6 +275,19 @@ class TradeDetailScreen extends ConsumerWidget { } } + /// CONTACT + Widget _buildContactButton(BuildContext context) { + return ElevatedButton( + onPressed: () { + context.push('/chat_room/$orderId'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('CONTACT'), + ); + } + /// RELEASE Widget _buildReleaseButton(WidgetRef ref) { final orderDetailsNotifier = @@ -341,7 +352,6 @@ class TradeDetailScreen extends ConsumerWidget { return ElevatedButton( onPressed: () async { await notifier.cancelOrder(); - context.pop(); }, style: ElevatedButton.styleFrom( backgroundColor: AppTheme.red1, diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index 0387737f..4581229d 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -82,13 +82,17 @@ class MostroMessageDetail extends ConsumerWidget { payload!.fiatAmount, payload.fiatCode, payload.paymentMethod, - payload.sellerTradePubkey!, + payload.sellerTradePubkey ?? session!.peer!.publicKey, ); break; case actions.Action.buyerTookOrder: final payload = mostroMessage.getPayload(); - actionText = S.of(context)!.buyerTookOrder(payload!.buyerTradePubkey!, - payload.fiatCode, payload.fiatAmount, payload.paymentMethod); + actionText = S.of(context)!.buyerTookOrder( + payload!.buyerTradePubkey ?? session!.peer!.publicKey, + payload.fiatCode, + payload.fiatAmount, + payload.paymentMethod, + ); break; case actions.Action.fiatSentOk: final payload = mostroMessage.getPayload(); @@ -103,9 +107,7 @@ class MostroMessageDetail extends ConsumerWidget { actionText = S.of(context)!.purchaseCompleted; break; case actions.Action.holdInvoicePaymentSettled: - actionText = S - .of(context)! - .holdInvoicePaymentSettled('{buyer_npub}'); + actionText = S.of(context)!.holdInvoicePaymentSettled('{buyer_npub}'); break; case actions.Action.rate: actionText = S.of(context)!.rate; diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 22985297..c0c52032 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; @@ -31,9 +32,18 @@ final appInitializerProvider = FutureProvider((ref) async { for (final session in sessionManager.sessions) { if (session.orderId != null) { await mostroService.sync(session); - final order = ref.watch(orderNotifierProvider(session.orderId!).notifier); + final order = ref.watch( + orderNotifierProvider(session.orderId!).notifier, + ); order.resubscribe(); } + + if (session.peer != null) { + final chat = ref.watch( + chatRoomsProvider(session.orderId!).notifier, + ); + chat.subscribe(); + } } }); diff --git a/lib/shared/providers/avatar_provider.dart b/lib/shared/providers/avatar_provider.dart new file mode 100644 index 00000000..4da5d648 --- /dev/null +++ b/lib/shared/providers/avatar_provider.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +final List kPossibleIcons = [ + Icons.person, + Icons.star, + Icons.favorite, + Icons.lock, + Icons.adb, + Icons.bolt, + Icons.casino, + Icons.visibility, + Icons.language, + Icons.face, + Icons.thumb_up, + Icons.pets, + Icons.hotel_class, + Icons.anchor, + Icons.school, + Icons.public, + Icons.construction, + Icons.emoji_emotions, + Icons.whatshot, + Icons.waving_hand, + Icons.nights_stay, + Icons.cruelty_free, + Icons.outdoor_grill, + Icons.sports_motorsports, + Icons.sports_football, + Icons.skateboarding, + Icons.sports_martial_arts, + Icons.paragliding, + Icons.face_6, + Icons.south_america, + Icons.face_2, + Icons.tsunami, + Icons.local_shipping, + Icons.flight, + Icons.directions_run, + Icons.lunch_dining, + Icons.directions_boat, +]; + +/// Deterministically pick one IconData from [kPossibleIcons], +/// based on the user’s 32-byte hex pubkey. +IconData pickNymIcon(String hexPubKey) { + final pubKeyBigInt = BigInt.parse(hexPubKey, radix: 16); + final index = (pubKeyBigInt % BigInt.from(kPossibleIcons.length)).toInt(); + return kPossibleIcons[index]; +} + +/// Deterministically pick a color from the 32-byte pubkey. +Color pickNymColor(String hexPubKey) { + final pubKeyBigInt = BigInt.parse(hexPubKey, radix: 16); + final hue = (pubKeyBigInt % BigInt.from(360)).toInt().toDouble(); + return HSVColor.fromAHSV(1.0, hue, 0.6, 0.8).toColor(); +} + +class NymAvatar extends StatelessWidget { + final String pubkeyHex; + final double size; + + const NymAvatar({ + super.key, + required this.pubkeyHex, + this.size = 32.0, + }); + + @override + Widget build(BuildContext context) { + final icon = pickNymIcon(pubkeyHex); + final color = pickNymColor(pubkeyHex); + + return CircleAvatar( + radius: (size) - 8, + backgroundColor: color, + child: CircleAvatar( + child: Icon( + icon, + size: size, + color: color, + ), + ), + ); + } +} diff --git a/lib/shared/providers/legible_hande_provider.dart b/lib/shared/providers/legible_hande_provider.dart new file mode 100644 index 00000000..f3965915 --- /dev/null +++ b/lib/shared/providers/legible_hande_provider.dart @@ -0,0 +1,139 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// A small Dart "library" that generates a memorable, on-theme nickname +/// from a 32-byte (64-hex-char) public key string. Ideal for ephemeral +/// or No-KYC usage. Collisions are possible with many users; expand +/// lists for a bigger namespace if needed. + +/// Some playful and thematic adjectives (Bitcoin/Nostr/privacy vibes + more). +const List kAdjectives = [ + 'shadowy', + 'orange', + 'lightning', + 'p2p', + 'noncustodial', + 'trustless', + 'unbanked', + 'atomic', + 'magic', + 'tor', + 'hidden', + 'incognito', + 'anonymous', + 'encrypted', + 'ghostly', + 'silent', + 'masked', + 'stealthy', + 'free', + 'nostalgic', + 'ephemeral', + 'sovereign', + 'unstoppable', + 'private', + 'censorshipresistant', + 'hush', + 'defiant', + 'subversive', + 'fiery', + 'subzero', + 'burning', + 'cosmic', + 'mighty', + 'whispering', + 'cyber', + 'rusty', + 'nihilistic', + 'mempool', + 'dark', + 'wicked', + 'spicy', + 'noKYC', + 'discreet', + 'loose', + 'boosted', + 'starving', + 'hungry', + 'orwellian', + 'bullish', + 'bearish', +]; + +/// Some nouns mixing animals, Bitcoin legends, Nostr references, & places. +const List kNouns = [ + 'wizard', + 'pirate', + 'zap', + 'node', + 'invoice', + 'nipster', + 'nomad', + 'sats', + 'bull', + 'bear', + 'whale', + 'frog', + 'gorilla', + 'nostrich', + 'halfinney', + 'hodlonaut', + 'satoshi', + 'nakamoto', + 'gigi', + 'samurai', + 'crusader', + 'tinkerer', + 'nostr', + 'pleb', + 'warrior', + 'ecdsa', + 'monkey', + 'wolf', + 'renegade', + 'minotaur', + 'phoenix', + 'dragon', + 'fiatjaf', + 'jackmallers', + 'roasbeef', + 'berlin', + 'tokyo', + 'buenosaires', + 'miami', + 'prague', + 'amsterdam', + 'lugano', + 'seoul', + 'bitcoinbeach', + 'odell', + 'bitcoinkid', + 'marty', + 'finney', + 'carnivore', + 'ape', + 'honeybadger', +]; + +/// Convert a 32-byte hex string (64 hex chars) into a fun, deterministic handle. +/// Example result: "shadowy-wizard", "noKYC-satoshi", etc. +String deterministicHandleFromHexKey(String hexKey) { + // 1) Parse the 64-char hex into a BigInt. + // Because it's 32 bytes, there's up to 256 bits of data here. + final pubKeyBigInt = BigInt.parse(hexKey, radix: 16); + + // 2) Use modulo arithmetic to pick an adjective and a noun. + final adjectivesCount = kAdjectives.length; + final nounsCount = kNouns.length; + + final indexAdjective = pubKeyBigInt % BigInt.from(adjectivesCount); + final indexNoun = (pubKeyBigInt ~/ BigInt.from(adjectivesCount)) % + BigInt.from(nounsCount); + + final adjective = kAdjectives[indexAdjective.toInt()]; + final noun = kNouns[indexNoun.toInt()]; + + return '$adjective-$noun'; +} + +final nickNameProvider = Provider.family( + (ref, pubkey) => deterministicHandleFromHexKey(pubkey)); diff --git a/lib/shared/utils/nostr_utils.dart b/lib/shared/utils/nostr_utils.dart index 24182c66..309eb56e 100644 --- a/lib/shared/utils/nostr_utils.dart +++ b/lib/shared/utils/nostr_utils.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:math'; +import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; import 'package:dart_nostr/dart_nostr.dart'; import 'package:elliptic/elliptic.dart'; @@ -8,7 +9,6 @@ import 'package:nip44/nip44.dart'; class NostrUtils { static final Nostr _instance = Nostr.instance; - // Generación de claves static NostrKeyPairs generateKeyPair() { try { @@ -70,7 +70,8 @@ class NostrUtils { // Firma y verificación static String signMessage(String message, String privateKey) { - return _instance.services.keys.sign(privateKey: privateKey, message: message); + return _instance.services.keys + .sign(privateKey: privateKey, message: message); } static bool verifySignature( @@ -158,7 +159,7 @@ class NostrUtils { ); try { - return await _encryptNIP44( + return await encryptNIP44( jsonEncode(rumorEvent.toMap()), wrapperKey, recipientPubKey); } catch (e) { throw Exception('Failed to encrypt content: $e'); @@ -177,7 +178,7 @@ class NostrUtils { createdAt: randomNow(), ); - return await _encryptNIP44( + return await encryptNIP44( jsonEncode(sealEvent.toMap()), wrapperKey, recipientPubKey); } @@ -196,6 +197,12 @@ class NostrUtils { return wrapEvent; } + static NostrKeyPairs computeSharedKey(String privateKey, String publicKey) { + final sharedKey = Nip44.computeSharedSecret(privateKey, publicKey); + final nkey = hex.encode(sharedKey); + return NostrKeyPairs(private: nkey); + } + /// 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 @@ -213,8 +220,8 @@ class NostrUtils { final senderKeyPair = generateKeyPairFromPrivateKey(senderPrivateKey); - String encryptedContent = - await createRumor(senderKeyPair, senderKeyPair.private, recipientPubKey, content); + String encryptedContent = await createRumor( + senderKeyPair, senderKeyPair.private, recipientPubKey, content); final wrapperKeyPair = generateKeyPair(); @@ -229,6 +236,9 @@ class NostrUtils { static Future decryptNIP59Event( NostrEvent event, String privateKey) async { + if (event.kind != 1059) { + throw ArgumentError('Wrong kind: ${event.kind}'); + } // Validate inputs if (event.content == null || event.content!.isEmpty) { throw ArgumentError('Event content is empty'); @@ -238,14 +248,20 @@ class NostrUtils { } try { - final decryptedContent = - await _decryptNIP44(event.content ?? '', privateKey, event.pubkey); + 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 finalDecryptedContent = await decryptNIP44( + rumorEvent.content!, + privateKey, + rumorEvent.pubkey, + ); final wrap = jsonDecode(finalDecryptedContent) as Map; @@ -296,7 +312,7 @@ class NostrUtils { } } - static Future _encryptNIP44( + static Future encryptNIP44( String content, String privkey, String pubkey) async { try { return await Nip44.encryptMessage(content, privkey, pubkey); @@ -306,7 +322,7 @@ class NostrUtils { } } - static Future _decryptNIP44( + static Future decryptNIP44( String encryptedContent, String privkey, String pubkey) async { try { return await Nip44.decryptMessage(encryptedContent, privkey, pubkey); diff --git a/lib/shared/widgets/bottom_nav_bar.dart b/lib/shared/widgets/bottom_nav_bar.dart index 50d4cedb..aea34ed1 100644 --- a/lib/shared/widgets/bottom_nav_bar.dart +++ b/lib/shared/widgets/bottom_nav_bar.dart @@ -14,7 +14,8 @@ class BottomNavBar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { // Watch the notification counts. final int chatCount = ref.watch(chatCountProvider); - final int orderNotificationCount = ref.watch(orderBookNotificationCountProvider); + final int orderNotificationCount = + ref.watch(orderBookNotificationCountProvider); return Container( padding: const EdgeInsets.symmetric(vertical: 16), @@ -29,22 +30,28 @@ class BottomNavBar extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildNavItem(context, HeroIcons.bookOpen, 0), - // For order book notifications (index 1) + _buildNavItem( + context, + HeroIcons.bookOpen, + 0, + ), _buildNavItem( context, HeroIcons.bookmarkSquare, 1, notificationCount: orderNotificationCount, ), - // For chat messages (index 2) _buildNavItem( context, HeroIcons.chatBubbleLeftRight, 2, notificationCount: chatCount, ), - _buildNavItem(context, HeroIcons.bolt, 3), + _buildNavItem( + context, + HeroIcons.bolt, + 3, + ), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index 4af231b7..498377c5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -652,6 +652,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + line_icons: + dependency: "direct main" + description: + name: line_icons + sha256: "249d781d922f5437ac763d9c8f5a02cf5b499a6dc3f85e4b92e074cff0a932ab" + url: "https://pub.dev" + source: hosted + version: "2.0.3" lints: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1b0e4033..bddfe40b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,6 +76,7 @@ dependencies: path: app_sembast version: '>=0.1.0' circular_countdown: ^2.1.0 + line_icons: ^2.0.3 dev_dependencies: flutter_test: From 7a09bcb3c897523e6d3b04a0631ee770b0ad884a Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 29 Mar 2025 17:14:23 +1000 Subject: [PATCH 087/149] Updated Drawer Added Walkthrough Feature --- lib/core/app_routes.dart | 14 +- .../notifiers/chat_room_notifier.dart | 0 .../notifiers/chat_rooms_notifier.dart | 2 +- .../providers/chat_room_providers.dart | 4 +- .../screens/chat_room_screen.dart | 2 +- .../screens/chat_rooms_list.dart | 4 +- lib/features/mostro/mostro_screen.dart | 80 --------- .../notfiers/abstract_order_notifier.dart | 2 +- .../screens/walkthrough_screen.dart | 73 +++++++++ lib/shared/providers/app_init_provider.dart | 2 +- lib/shared/widgets/bottom_nav_bar.dart | 10 -- lib/shared/widgets/mostro_app_drawer.dart | 40 ++++- pubspec.lock | 154 +++++++++++++----- pubspec.yaml | 1 + 14 files changed, 235 insertions(+), 153 deletions(-) rename lib/features/{messages => chat}/notifiers/chat_room_notifier.dart (100%) rename lib/features/{messages => chat}/notifiers/chat_rooms_notifier.dart (91%) rename lib/features/{messages => chat}/providers/chat_room_providers.dart (80%) rename lib/features/{messages => chat}/screens/chat_room_screen.dart (98%) rename lib/features/{messages => chat}/screens/chat_rooms_list.dart (97%) delete mode 100644 lib/features/mostro/mostro_screen.dart create mode 100644 lib/features/walkthrough/screens/walkthrough_screen.dart diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index 3387b406..5bebe4a1 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; -import 'package:mostro_mobile/features/messages/screens/chat_room_screen.dart'; +import 'package:mostro_mobile/features/chat/screens/chat_room_screen.dart'; import 'package:mostro_mobile/features/order/screens/add_order_screen.dart'; import 'package:mostro_mobile/features/order/screens/order_confirmation_screen.dart'; import 'package:mostro_mobile/features/auth/screens/welcome_screen.dart'; -import 'package:mostro_mobile/features/messages/screens/chat_rooms_list.dart'; +import 'package:mostro_mobile/features/chat/screens/chat_rooms_list.dart'; import 'package:mostro_mobile/features/home/screens/home_screen.dart'; import 'package:mostro_mobile/features/key_manager/key_management_screen.dart'; -import 'package:mostro_mobile/features/mostro/mostro_screen.dart'; import 'package:mostro_mobile/features/rate/rate_counterpart_screen.dart'; import 'package:mostro_mobile/features/settings/about_screen.dart'; import 'package:mostro_mobile/features/settings/settings_screen.dart'; @@ -19,6 +18,7 @@ import 'package:mostro_mobile/features/order/screens/add_lightning_invoice_scree import 'package:mostro_mobile/features/order/screens/pay_lightning_invoice_screen.dart'; import 'package:mostro_mobile/features/order/screens/take_order_screen.dart'; import 'package:mostro_mobile/features/auth/screens/register_screen.dart'; +import 'package:mostro_mobile/features/walkthrough/screens/walkthrough_screen.dart'; import 'package:mostro_mobile/shared/widgets/navigation_listener_widget.dart'; import 'package:mostro_mobile/shared/widgets/notification_listener_widget.dart'; @@ -66,10 +66,6 @@ final goRouter = GoRouter( path: '/register', builder: (context, state) => const RegisterScreen(), ), - GoRoute( - path: '/mostro', - builder: (context, state) => const MostroScreen(), - ), GoRoute( path: '/relays', builder: (context, state) => const RelaysScreen(), @@ -86,6 +82,10 @@ final goRouter = GoRouter( path: '/about', builder: (context, state) => const AboutScreen(), ), + GoRoute( + path: '/walkthrough', + builder: (context, state) => WalkthroughScreen(), + ), GoRoute( path: '/add_order', builder: (context, state) => AddOrderScreen(), diff --git a/lib/features/messages/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart similarity index 100% rename from lib/features/messages/notifiers/chat_room_notifier.dart rename to lib/features/chat/notifiers/chat_room_notifier.dart diff --git a/lib/features/messages/notifiers/chat_rooms_notifier.dart b/lib/features/chat/notifiers/chat_rooms_notifier.dart similarity index 91% rename from lib/features/messages/notifiers/chat_rooms_notifier.dart rename to lib/features/chat/notifiers/chat_rooms_notifier.dart index c62eebe6..d69e88dc 100644 --- a/lib/features/messages/notifiers/chat_rooms_notifier.dart +++ b/lib/features/chat/notifiers/chat_rooms_notifier.dart @@ -1,7 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; -import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; diff --git a/lib/features/messages/providers/chat_room_providers.dart b/lib/features/chat/providers/chat_room_providers.dart similarity index 80% rename from lib/features/messages/providers/chat_room_providers.dart rename to lib/features/chat/providers/chat_room_providers.dart index d9adbbfc..0d8cb728 100644 --- a/lib/features/messages/providers/chat_room_providers.dart +++ b/lib/features/chat/providers/chat_room_providers.dart @@ -1,7 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; -import 'package:mostro_mobile/features/messages/notifiers/chat_room_notifier.dart'; -import 'package:mostro_mobile/features/messages/notifiers/chat_rooms_notifier.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_room_notifier.dart'; +import 'package:mostro_mobile/features/chat/notifiers/chat_rooms_notifier.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; final chatRoomsNotifierProvider = diff --git a/lib/features/messages/screens/chat_room_screen.dart b/lib/features/chat/screens/chat_room_screen.dart similarity index 98% rename from lib/features/messages/screens/chat_room_screen.dart rename to lib/features/chat/screens/chat_room_screen.dart index 0d4aca3d..925b5dcd 100644 --- a/lib/features/messages/screens/chat_room_screen.dart +++ b/lib/features/chat/screens/chat_room_screen.dart @@ -7,7 +7,7 @@ import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; import 'package:mostro_mobile/shared/providers/legible_hande_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; diff --git a/lib/features/messages/screens/chat_rooms_list.dart b/lib/features/chat/screens/chat_rooms_list.dart similarity index 97% rename from lib/features/messages/screens/chat_rooms_list.dart rename to lib/features/chat/screens/chat_rooms_list.dart index 5afc9652..fc4705ea 100644 --- a/lib/features/messages/screens/chat_rooms_list.dart +++ b/lib/features/chat/screens/chat_rooms_list.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; -import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; import 'package:mostro_mobile/shared/providers/legible_hande_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; @@ -34,7 +34,7 @@ class ChatRoomsScreen extends ConsumerWidget { Padding( padding: const EdgeInsets.all(16.0), child: Text( - 'MESSAGES', + 'CHAT', style: TextStyle(color: AppTheme.mostroGreen), ), ), diff --git a/lib/features/mostro/mostro_screen.dart b/lib/features/mostro/mostro_screen.dart deleted file mode 100644 index 40f1f42b..00000000 --- a/lib/features/mostro/mostro_screen.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; -import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; -import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; -import 'package:mostro_mobile/shared/widgets/mostro_app_drawer.dart'; - -class MostroScreen extends ConsumerWidget { - const MostroScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final nostrEvent = ref.read(orderRepositoryProvider).mostroInstance; - final List mostroMessages = []; // ref.read(mostroStorageProvider).getAllOrders(); - - return nostrEvent == null - ? Scaffold( - appBar: const MostroAppBar(), - drawer: const MostroAppDrawer(), - backgroundColor: AppTheme.dark1, - body: const Center(child: CircularProgressIndicator()), - bottomNavigationBar: const BottomNavBar(), - ) - : Scaffold( - backgroundColor: AppTheme.dark1, - appBar: const MostroAppBar(), - drawer: const MostroAppDrawer(), - body: RefreshIndicator( - onRefresh: () async { - // Trigger a refresh of your providers - //ref.refresh(mostroInstanceProvider); - //ref.refresh(mostroMessagesProvider); - }, - child: Container( - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.dark2, - borderRadius: BorderRadius.circular(20), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Mostro Messages', - style: AppTheme.theme.textTheme.displayLarge, - ), - ), - const SizedBox(height: 24), - // List of messages - Expanded( - child: ListView.builder( - itemCount: mostroMessages.length, - itemBuilder: (context, index) { - final message = mostroMessages[index]; - return _buildMessageTile(message); - }, - ), - ), - const BottomNavBar(), - ], - ), - ), - ), - ); - } - - Widget _buildMessageTile(MostroMessage message) { - return ListTile( - title: Text( - 'Order Type: ${message.action}', - ), - subtitle: Text( - 'Order Id: ${message.payload?.toJson()}', - ), - ); - } -} diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_order_notifier.dart index 62eee03a..43c84d63 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_order_notifier.dart @@ -8,7 +8,7 @@ import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/peer.dart'; -import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; diff --git a/lib/features/walkthrough/screens/walkthrough_screen.dart b/lib/features/walkthrough/screens/walkthrough_screen.dart new file mode 100644 index 00000000..b5a3da4d --- /dev/null +++ b/lib/features/walkthrough/screens/walkthrough_screen.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:introduction_screen/introduction_screen.dart'; +import 'package:go_router/go_router.dart'; + +class WalkthroughScreen extends StatelessWidget { + // Define your walkthrough pages – update texts, images and background colors as needed. + final List pages = [ + PageViewModel( + title: "Welcome to Mostro Mobile", + body: + "Discover a secure, private, and efficient platform for peer-to-peer trading.", + image: Center( + child: Image.asset("assets/images/mostro-icons.png", height: 175.0), + ), + decoration: const PageDecoration( + titleTextStyle: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + bodyTextStyle: TextStyle(fontSize: 16), + ), + ), + PageViewModel( + title: "Easy Onboarding", + body: "Our guided walkthrough makes it simple to get started.", + image: Center( + child: Image.asset("assets/images/logo.png", height: 175.0), + ), + decoration: const PageDecoration( + titleTextStyle: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + bodyTextStyle: TextStyle(fontSize: 16), + ), + ), + PageViewModel( + title: "Trade with Confidence", + body: "Enjoy seamless peer-to-peer trades using our advanced protocols.", + image: Center( + child: Image.asset("assets/images/logo.png", height: 175.0), + ), + decoration: const PageDecoration( + titleTextStyle: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + bodyTextStyle: TextStyle(fontSize: 16), + ), + ), + ]; + + WalkthroughScreen({super.key}); + + void _onIntroEnd(BuildContext context) { + context.pop(); + } + + @override + Widget build(BuildContext context) { + // Use your app's theme colors. + final theme = Theme.of(context); + return IntroductionScreen( + pages: pages, + onDone: () => _onIntroEnd(context), + onSkip: () => _onIntroEnd(context), + showSkipButton: true, + skip: const Text("Skip"), + next: const Icon(Icons.arrow_forward), + done: const Text("Done", style: TextStyle(fontWeight: FontWeight.w600)), + dotsDecorator: DotsDecorator( + activeColor: theme.primaryColor, + size: const Size(10, 10), + color: theme.cardColor, + activeSize: const Size(22, 10), + activeShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25.0), + ), + ), + ); + } +} diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index c0c52032..eaae2085 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -3,7 +3,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; -import 'package:mostro_mobile/features/messages/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; diff --git a/lib/shared/widgets/bottom_nav_bar.dart b/lib/shared/widgets/bottom_nav_bar.dart index aea34ed1..d65bf1ce 100644 --- a/lib/shared/widgets/bottom_nav_bar.dart +++ b/lib/shared/widgets/bottom_nav_bar.dart @@ -47,11 +47,6 @@ class BottomNavBar extends ConsumerWidget { 2, notificationCount: chatCount, ), - _buildNavItem( - context, - HeroIcons.bolt, - 3, - ), ], ), ), @@ -108,8 +103,6 @@ class BottomNavBar extends ConsumerWidget { return currentLocation == '/order_book'; case 2: return currentLocation == '/chat_list'; - case 3: - return currentLocation == '/mostro'; default: return false; } @@ -127,9 +120,6 @@ class BottomNavBar extends ConsumerWidget { case 2: nextRoute = '/chat_list'; break; - case 3: - nextRoute = '/mostro'; - break; default: return; } diff --git a/lib/shared/widgets/mostro_app_drawer.dart b/lib/shared/widgets/mostro_app_drawer.dart index a11872b7..91ac8cb3 100644 --- a/lib/shared/widgets/mostro_app_drawer.dart +++ b/lib/shared/widgets/mostro_app_drawer.dart @@ -20,23 +20,57 @@ class MostroAppDrawer extends StatelessWidget { child: Stack(), ), ListTile( - title: const Text('Key Management'), + leading: Icon( + Icons.person_outline_sharp, + color: AppTheme.cream1, + ), + title: Text( + 'Account', + style: AppTheme.theme.textTheme.headlineMedium, + ), onTap: () { context.push('/key_management'); }, ), ListTile( - title: const Text('Settings'), + leading: Icon( + Icons.settings_outlined, + color: AppTheme.cream1, + ), + title: Text( + 'Settings', + style: AppTheme.theme.textTheme.headlineMedium, + ), onTap: () { context.push('/settings'); }, ), ListTile( - title: const Text('About'), + leading: Icon( + Icons.info_outlined, + color: AppTheme.cream1, + ), + title: Text( + 'About', + style: AppTheme.theme.textTheme.headlineMedium, + ), onTap: () { context.push('/about'); }, ), + ListTile( + leading: Icon( + Icons.menu_book_sharp, + color: AppTheme.cream1, + ), + title: Text( + 'Walkthrough', + style: AppTheme.theme.textTheme.headlineMedium, + ), + onTap: () { + context.push('/walkthrough'); + }, + ), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index 498377c5..5e98ce0d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,18 +21,18 @@ packages: dependency: transitive description: name: archive - sha256: "6199c74e3db4fbfbd04f66d739e72fe11c8a8957d5f219f1f4482dbde6420b5a" + sha256: "7dcbd0f87fe5f61cb28da39a1a8b70dbc106e2fe0516f7836eb7bb2948481a12" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.0.5" args: dependency: transitive description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: @@ -173,10 +173,10 @@ packages: dependency: transitive description: name: built_value - sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2" + sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 url: "https://pub.dev" source: hosted - version: "8.9.3" + version: "8.9.5" characters: dependency: transitive description: @@ -277,18 +277,18 @@ packages: dependency: transitive description: name: dart_flutter_team_lints - sha256: "241f050cb4d908caffdcc5d4407e1c4e0b1dd449294ef7b6ceb4a9d8bc77dd96" + sha256: "4c8f38142598339cd28c0b48a66b6b04434ee0499b6e40baf7c62c76daa1fcad" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.5.1" dart_nostr: dependency: "direct main" description: name: dart_nostr - sha256: abcf02ee243a156aea494206a9368640a4cffdd458dfdafb92d2fc2f8d883a8f + sha256: "13ef2c7b45ad3bb52448098c7ef517ba8f70e563e17fba17d3c26e6104bdf0c1" url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "9.1.0" dart_style: dependency: transitive description: @@ -301,10 +301,18 @@ packages: dependency: transitive description: name: dev_build - sha256: "5ed4fe61cead9fd561f13c7a05f6c11002ecc8ec6f5be33f7b9674f49f245434" + sha256: "87abaa4f6cfd50320b229e29a7ef5532d86bc804f3124328c7e9db238ba45b33" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.2+8" + dots_indicator: + dependency: transitive + description: + name: dots_indicator + sha256: c070af5058a084ba7b354df4b4c26c719595d70a3531eea6edd8af8716684ba3 + url: "https://pub.dev" + source: hosted + version: "4.0.1" elliptic: dependency: "direct main" description: @@ -379,6 +387,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.1" + flutter_keyboard_visibility: + dependency: transitive + description: + name: flutter_keyboard_visibility + sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_web: + dependency: transitive + description: + name: flutter_keyboard_visibility_web + sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter_launcher_icons: dependency: "direct main" description: @@ -404,10 +460,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" + sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" url: "https://pub.dev" source: hosted - version: "2.0.24" + version: "2.0.27" flutter_riverpod: dependency: "direct main" description: @@ -507,10 +563,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "04539267a740931c6d4479a10d466717ca5901c6fdfd3fcda09391bbb8ebd651" + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 url: "https://pub.dev" source: hosted - version: "14.8.0" + version: "14.8.1" google_fonts: dependency: "direct main" description: @@ -579,18 +635,18 @@ packages: dependency: transitive description: name: idb_shim - sha256: "2fe625a67b693ae9b7d02bc8f3ff7c5176ab6be5c9e4ac2cd11924fbb54b5a6c" + sha256: d3dae2085f2dcc9d05b851331fddb66d57d3447ff800de9676b396795436e135 url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.6.5+1" image: dependency: transitive description: name: image - sha256: "8346ad4b5173924b5ddddab782fc7d8a6300178c8b1dc427775405a01701c4a6" + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.4" integration_test: dependency: "direct dev" description: flutter @@ -604,6 +660,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + introduction_screen: + dependency: "direct main" + description: + name: introduction_screen + sha256: "02c123e074ee85b0ad0a8d3cd990f1eae78d5cfb2ac4588ad3703ac2a07fb5b0" + url: "https://pub.dev" + source: hosted + version: "3.1.17" io: dependency: transitive description: @@ -680,10 +744,10 @@ packages: dependency: transitive description: name: local_auth_android - sha256: "6763aaf8965f21822624cb2fd3c03d2a8b3791037b5efb0fe4b13e110f5afc92" + sha256: "0abe4e72f55c785b28900de52a2522c86baba0988838b5dc22241b072ecccd74" url: "https://pub.dev" source: hosted - version: "1.0.46" + version: "1.0.48" local_auth_darwin: dependency: transitive description: @@ -785,10 +849,10 @@ packages: dependency: transitive description: name: package_config - sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" path: dependency: "direct main" description: @@ -817,10 +881,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" url: "https://pub.dev" source: hosted - version: "2.2.15" + version: "2.2.16" path_provider_foundation: dependency: transitive description: @@ -913,18 +977,18 @@ packages: dependency: transitive description: name: process_run - sha256: "01a48e04fbb489d1e4610ed361f6b3f21aea0966dfa0ff9227e9c4095cd6d3fb" + sha256: "6ec839cdd3e6de4685318e7686cd4abb523c3d3a55af0e8d32a12ae19bc66622" url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.2.4" pub_semver: dependency: transitive description: name: pub_semver - sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" pubspec_parse: dependency: transitive description: @@ -961,10 +1025,10 @@ packages: dependency: "direct main" description: name: sembast - sha256: f90377eb5ef66ac5a42d7afd72f4f900c436a2528d6b67b318fb2b9f5484bbe5 + sha256: "06b0274ca48ea92aedeab62303fc9ade3272684c842e9447be3e9b040f935559" url: "https://pub.dev" source: hosted - version: "3.8.4" + version: "3.8.4+1" sembast_sqflite: dependency: transitive description: @@ -985,18 +1049,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "846849e3e9b68f3ef4b60c60cf4b3e02e9321bc7f4d8c4692cf87ffa82fc8a3a" + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.5.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: a768fc8ede5f0c8e6150476e14f38e2417c0864ca36bb4582be8e21925a03c22 + sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.8" shared_preferences_foundation: dependency: transitive description: @@ -1174,10 +1238,10 @@ packages: dependency: transitive description: name: sqlite3 - sha256: "32b632dda27d664f85520093ed6f735ae5c49b5b75345afb8b19411bc59bb53d" + sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e" url: "https://pub.dev" source: hosted - version: "2.7.4" + version: "2.7.5" stack_trace: dependency: transitive description: @@ -1239,7 +1303,7 @@ packages: description: path: app_sembast ref: dart3a - resolved-ref: "87c10a1981e0cc63b9b5867a80f1c6508d20c4fa" + resolved-ref: "485d95cc933ccf373e7b4a53c19e8f43194c66c5" url: "https://github.com/tekartik/app_flutter_utils.dart" source: git version: "0.5.1" @@ -1248,7 +1312,7 @@ packages: description: path: app_sqflite ref: dart3a - resolved-ref: "87c10a1981e0cc63b9b5867a80f1c6508d20c4fa" + resolved-ref: "485d95cc933ccf373e7b4a53c19e8f43194c66c5" url: "https://github.com/tekartik/app_flutter_utils.dart" source: git version: "0.6.1" @@ -1394,10 +1458,10 @@ packages: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket_channel: dependency: transitive description: @@ -1426,10 +1490,10 @@ packages: dependency: transitive description: name: win32 - sha256: b89e6e24d1454e149ab20fbb225af58660f0c0bf4475544650700d8e2da54aef + sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f url: "https://pub.dev" source: hosted - version: "5.11.0" + version: "5.12.0" xdg_directories: dependency: transitive description: @@ -1456,4 +1520,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.7.0 <=3.9.9" - flutter: ">=3.24.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index bddfe40b..928a5ae5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -77,6 +77,7 @@ dependencies: version: '>=0.1.0' circular_countdown: ^2.1.0 line_icons: ^2.0.3 + introduction_screen: ^3.1.17 dev_dependencies: flutter_test: From 2453ef4d905f12fe24e0c0c0a6102a19816246c7 Mon Sep 17 00:00:00 2001 From: Biz Date: Fri, 4 Apr 2025 11:48:32 +1000 Subject: [PATCH 088/149] Refdactored Data Storage and Notifiers --- lib/data/models/cant_do.dart | 2 +- lib/data/models/nostr_event.dart | 24 + lib/data/models/payload.dart | 2 +- lib/data/models/payment_request.dart | 4 +- lib/data/repositories/base_storage.dart | 5 + lib/data/repositories/event_storage.dart | 42 ++ lib/data/repositories/mostro_storage.dart | 125 +++-- lib/data/repositories/order_storage.dart | 95 ++++ .../chat/screens/chat_room_screen.dart | 8 +- ...ier.dart => abstract_mostro_notifier.dart} | 54 +- .../order/notfiers/add_order_notifier.dart | 80 ++- .../order/notfiers/cant_do_notifier.dart | 24 + .../order/notfiers/order_notifier.dart | 38 +- .../notfiers/payment_request_notifier.dart | 18 + .../providers/order_notifier_provider.dart | 48 +- .../providers/order_notifier_provider.g.dart | 26 + .../order/screens/add_order_screen.dart | 4 +- .../screens/pay_lightning_invoice_screen.dart | 2 +- .../order/screens/take_order_screen.dart | 5 +- lib/features/order/widgets/order_app_bar.dart | 2 +- .../rate/rate_counterpart_screen.dart | 9 +- .../trades/screens/trade_detail_screen.dart | 2 +- .../widgets/mostro_message_detail_widget.dart | 12 +- lib/services/event_bus.dart | 22 + lib/services/event_bus.g.dart | 26 + lib/services/mostro_service.dart | 150 ++--- .../notifiers/order_action_notifier.dart | 16 + lib/shared/notifiers/session_notifier.dart | 4 +- lib/shared/providers/app_init_provider.dart | 19 +- .../providers/mostro_service_provider.dart | 31 +- .../providers/mostro_service_provider.g.dart | 43 ++ lib/shared/providers/session_providers.dart | 45 ++ lib/shared/providers/session_providers.g.dart | 308 +++++++++++ .../widgets/add_lightning_invoice_widget.dart | 6 +- ...widget.dart => clickable_text_widget.dart} | 26 +- pubspec.lock | 56 ++ pubspec.yaml | 8 +- test/mocks.dart | 3 +- test/mocks.mocks.dart | 323 ++--------- test/notifiers/add_order_notifier_test.dart | 22 +- test/notifiers/take_order_notifier_test.dart | 28 +- test/services/mostro_service_test.dart | 30 +- test/services/mostro_service_test.mocks.dart | 520 ++++++++++++------ 43 files changed, 1574 insertions(+), 743 deletions(-) create mode 100644 lib/data/repositories/event_storage.dart create mode 100644 lib/data/repositories/order_storage.dart rename lib/features/order/notfiers/{abstract_order_notifier.dart => abstract_mostro_notifier.dart} (85%) create mode 100644 lib/features/order/notfiers/cant_do_notifier.dart create mode 100644 lib/features/order/notfiers/payment_request_notifier.dart create mode 100644 lib/features/order/providers/order_notifier_provider.g.dart create mode 100644 lib/services/event_bus.dart create mode 100644 lib/services/event_bus.g.dart create mode 100644 lib/shared/notifiers/order_action_notifier.dart create mode 100644 lib/shared/providers/mostro_service_provider.g.dart create mode 100644 lib/shared/providers/session_providers.dart create mode 100644 lib/shared/providers/session_providers.g.dart rename lib/shared/widgets/{clickable_amount_widget.dart => clickable_text_widget.dart} (65%) diff --git a/lib/data/models/cant_do.dart b/lib/data/models/cant_do.dart index 8e552825..13d3974c 100644 --- a/lib/data/models/cant_do.dart +++ b/lib/data/models/cant_do.dart @@ -5,7 +5,7 @@ class CantDo implements Payload { final CantDoReason cantDoReason; factory CantDo.fromJson(Map json) { - return CantDo(cantDoReason: CantDoReason.fromString(json['cant_do'])); + return CantDo(cantDoReason: CantDoReason.fromString(json['cant-do'])); } CantDo({required this.cantDoReason}); diff --git a/lib/data/models/nostr_event.dart b/lib/data/models/nostr_event.dart index 05a4dea5..c619bfb7 100644 --- a/lib/data/models/nostr_event.dart +++ b/lib/data/models/nostr_event.dart @@ -133,4 +133,28 @@ extension NostrEventExtensions on NostrEvent { tags: tags, ); } + + static NostrEvent fromMap(Map event) { + return NostrEvent( + id: event['id'] as String, + kind: event['kind'] as int, + content: event['content'] == null ? '' : event['content'] as String, + sig: event['sig'] as String, + pubkey: event['pubkey'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch( + (event['created_at'] as int) * 1000, + ), + tags: List>.from( + (event['tags'] as List) + .map( + (nestedElem) => (nestedElem as List) + .map( + (nestedElemContent) => nestedElemContent.toString(), + ) + .toList(), + ) + .toList(), + ), + ); + } } diff --git a/lib/data/models/payload.dart b/lib/data/models/payload.dart index 85f00a9e..7b3c98df 100644 --- a/lib/data/models/payload.dart +++ b/lib/data/models/payload.dart @@ -15,7 +15,7 @@ abstract class Payload { } else if (json.containsKey('payment_request')) { return PaymentRequest.fromJson(json['payment_request']); } else if (json.containsKey('cant_do')) { - return CantDo.fromJson(json); + return CantDo.fromJson(json['cant_do']); } else if (json.containsKey('peer')) { return Peer.fromJson(json['peer']); } else if (json.containsKey('dispute')) { diff --git a/lib/data/models/payment_request.dart b/lib/data/models/payment_request.dart index 56851447..de07b79d 100644 --- a/lib/data/models/payment_request.dart +++ b/lib/data/models/payment_request.dart @@ -39,7 +39,9 @@ class PaymentRequest implements Payload { throw FormatException('Invalid JSON format: insufficient elements'); } final orderJson = json[0]; - final Order? order = orderJson != null ? Order.fromJson(orderJson) : null; + final Order? order = orderJson != null + ? Order.fromJson(orderJson['order'] ?? orderJson) + : null; final lnInvoice = json[1]; if (lnInvoice != null && lnInvoice is! String) { throw FormatException('Invalid type for lnInvoice: expected String'); diff --git a/lib/data/repositories/base_storage.dart b/lib/data/repositories/base_storage.dart index 92de7439..f242a133 100644 --- a/lib/data/repositories/base_storage.dart +++ b/lib/data/repositories/base_storage.dart @@ -31,6 +31,11 @@ abstract class BaseStorage { return fromDbMap(id, record); } + /// Check if an item exists in the data store by [id]. + Future hasItem(String id) async { + return await store.record(id).exists(db); + } + /// Return all items in the store. Future> getAllItems() async { final records = await store.find(db); diff --git a/lib/data/repositories/event_storage.dart b/lib/data/repositories/event_storage.dart new file mode 100644 index 00000000..a345452f --- /dev/null +++ b/lib/data/repositories/event_storage.dart @@ -0,0 +1,42 @@ +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:mostro_mobile/data/repositories/base_storage.dart'; +import 'package:sembast/sembast.dart'; + +class EventStorage extends BaseStorage { + EventStorage({ + required Database db, + }) : super( + db, + stringMapStoreFactory.store('events'), + ); + + @override + NostrEvent fromDbMap(String key, Map event) { + return NostrEvent( + id: event['id'] as String, + kind: event['kind'] as int, + content: event['content'] == null ? '' : event['content'] as String, + sig: event['sig'] as String, + pubkey: event['pubkey'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch( + (event['created_at'] as int) * 1000, + ), + tags: List>.from( + (event['tags'] as List) + .map( + (nestedElem) => (nestedElem as List) + .map( + (nestedElemContent) => nestedElemContent.toString(), + ) + .toList(), + ) + .toList(), + ), + ); + } + + @override + Map toDbMap(NostrEvent event) { + return event.toMap(); + } +} diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 3e3d7301..4c552de5 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -1,88 +1,124 @@ -import 'dart:async'; import 'package:logger/logger.dart'; -import 'package:mostro_mobile/data/repositories/base_storage.dart'; +import 'package:mostro_mobile/data/models/payload.dart'; import 'package:sembast/sembast.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/repositories/base_storage.dart'; class MostroStorage extends BaseStorage { final Logger _logger = Logger(); - MostroStorage({ - required Database db, - }) : super( - db, - stringMapStoreFactory.store('orders'), - ); - - Future init() async { - await getAllOrders(); - } - - /// Save or update a MostroMessage - Future addOrder(MostroMessage message) async { - final orderId = message.id; - if (orderId == null) { - throw ArgumentError('Cannot save an order with a null message.id'); - } + MostroStorage({required Database db}) + : super(db, stringMapStoreFactory.store('orders')); + /// Save or update any MostroMessage + Future addMessage(MostroMessage message) async { + final id = messageKey(message); try { - await putItem(orderId, message); - _logger.i('Order $orderId saved'); + await putItem(id, message); + _logger.i( + 'Saved message of type \${message.payload.runtimeType} with id \$id'); } catch (e, stack) { - _logger.e('addOrder failed for $orderId', error: e, stackTrace: stack); + _logger.e( + 'addMessage failed for \$id', + error: e, + stackTrace: stack, + ); rethrow; } } - Future addOrders(List orders) async { - for (final order in orders) { - addOrder(order); + /// Save or update a list of MostroMessages + Future addMessages(List messages) async { + for (final message in messages) { + await addMessage(message); } } - /// Retrieve an order by ID - Future getOrderById(String orderId) async { + /// Retrieve a MostroMessage by ID + Future getMessageById( + String orderId, + ) async { + final t = T; + final id = '$t:$orderId'; try { - return await getItem(orderId); + return await getItem(id); } catch (e, stack) { - _logger.e('Error deserializing order $orderId', + _logger.e('Error deserializing message \$id', error: e, stackTrace: stack); return null; } } - /// Return all orders - Future> getAllOrders() async { + /// Get all messages + Future> getAllMessages() async { try { return await getAllItems(); } catch (e, stack) { - _logger.e('getAllOrders failed', error: e, stackTrace: stack); + _logger.e('getAllMessages failed', error: e, stackTrace: stack); return []; } } - /// Delete an order from DB - Future deleteOrder(String orderId) async { + /// Delete a message by ID + Future deleteMessage(String orderId) async { + final id = '${T.runtimeType}:$orderId'; try { - await deleteItem(orderId); - _logger.i('Order $orderId deleted from DB'); + await deleteItem(id); + _logger.i('Message \$id deleted from DB'); } catch (e, stack) { - _logger.e('deleteOrder failed for $orderId', error: e, stackTrace: stack); + _logger.e('deleteMessage failed for \$id', error: e, stackTrace: stack); rethrow; } } - /// Delete all orders - Future deleteAllOrders() async { + /// Delete all messages + Future deleteAllMessages() async { try { await deleteAllItems(); - _logger.i('All orders deleted'); + _logger.i('All messages deleted'); + } catch (e, stack) { + _logger.e('deleteAllMessages failed', error: e, stackTrace: stack); + rethrow; + } + } + + /// Delete all messages by Id regardless of type + Future deleteAllMessagesById(String orderId) async { + try { + final messages = await getMessagesForId(orderId); + for (var m in messages) { + final id = messageKey(m); + try { + await deleteItem(id); + _logger.i('Message \$id deleted from DB'); + } catch (e, stack) { + _logger.e('deleteMessage failed for \$id', + error: e, stackTrace: stack); + rethrow; + } + } + _logger.i('All messages for order: $orderId deleted'); } catch (e, stack) { - _logger.e('deleteAllOrders failed', error: e, stackTrace: stack); + _logger.e('deleteAllMessagesForId failed', error: e, stackTrace: stack); rethrow; } } + /// Filter messages by payload type + Future>> getMessagesOfType() async { + final messages = await getAllMessages(); + return messages + .where((m) => m.payload is T) + .map((m) => m as MostroMessage) + .toList(); + } + + /// Filter messages by tradeKeyPublic + Future> getMessagesForId(String orderId) async { + final messages = await getAllMessages(); + return messages.where((m) => m.id == orderId).toList(); + } + @override MostroMessage fromDbMap(String key, Map jsonMap) { return MostroMessage.fromJson(jsonMap); @@ -92,4 +128,11 @@ class MostroStorage extends BaseStorage { Map toDbMap(MostroMessage item) { return item.toJson(); } + + String messageKey(MostroMessage msg) { + final type = + msg.payload != null ? msg.payload.runtimeType.toString() : 'Order'; + final id = msg.id ?? msg.requestId.toString(); + return '$type:$id'; + } } diff --git a/lib/data/repositories/order_storage.dart b/lib/data/repositories/order_storage.dart new file mode 100644 index 00000000..337856ac --- /dev/null +++ b/lib/data/repositories/order_storage.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/repositories/base_storage.dart'; +import 'package:sembast/sembast.dart'; + +class OrderStorage extends BaseStorage { + final Logger _logger = Logger(); + + OrderStorage({ + required Database db, + }) : super( + db, + stringMapStoreFactory.store('orders'), + ); + + Future init() async { + await getAllOrders(); + } + + /// Save or update an Order + Future addOrder(Order order) async { + final orderId = order.id; + if (orderId == null) { + throw ArgumentError('Cannot save an order with a null order.id'); + } + + try { + await putItem(orderId, order); + _logger.i('Order $orderId saved'); + } catch (e, stack) { + _logger.e('addOrder failed for $orderId', error: e, stackTrace: stack); + rethrow; + } + } + + Future addOrders(List orders) async { + for (final order in orders) { + addOrder(order); + } + } + + /// Retrieve an order by ID + Future getOrderById(String orderId) async { + try { + return await getItem(orderId); + } catch (e, stack) { + _logger.e('Error deserializing order $orderId', + error: e, stackTrace: stack); + return null; + } + } + + /// Return all orders + Future> getAllOrders() async { + try { + return await getAllItems(); + } catch (e, stack) { + _logger.e('getAllOrders failed', error: e, stackTrace: stack); + return []; + } + } + + /// Delete an order from DB + Future deleteOrder(String orderId) async { + try { + await deleteItem(orderId); + _logger.i('Order $orderId deleted from DB'); + } catch (e, stack) { + _logger.e('deleteOrder failed for $orderId', error: e, stackTrace: stack); + rethrow; + } + } + + /// Delete all orders + Future deleteAllOrders() async { + try { + await deleteAllItems(); + _logger.i('All orders deleted'); + } catch (e, stack) { + _logger.e('deleteAllOrders failed', error: e, stackTrace: stack); + rethrow; + } + } + + @override + Order fromDbMap(String key, Map jsonMap) { + return Order.fromJson(jsonMap); + } + + @override + Map toDbMap(Order item) { + return item.toJson(); + } +} diff --git a/lib/features/chat/screens/chat_room_screen.dart b/lib/features/chat/screens/chat_room_screen.dart index 925b5dcd..97481fa7 100644 --- a/lib/features/chat/screens/chat_room_screen.dart +++ b/lib/features/chat/screens/chat_room_screen.dart @@ -11,6 +11,7 @@ import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; import 'package:mostro_mobile/shared/providers/legible_hande_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; +import 'package:mostro_mobile/shared/widgets/clickable_text_widget.dart'; class ChatRoomScreen extends ConsumerStatefulWidget { final String orderId; @@ -150,7 +151,7 @@ class _MessagesDetailScreenState extends ConsumerState { Widget _buildMessageHeader(String peerPubkey, Session session) { final handle = ref.read(nickNameProvider(peerPubkey)); final you = ref.read(nickNameProvider(session.tradeKey.public)); - final sharedKey = session.sharedKey?.public; + final sharedKey = session.sharedKey?.private; return Container( margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), @@ -177,7 +178,10 @@ class _MessagesDetailScreenState extends ConsumerState { ), const SizedBox(height: 4), Text('Your handle: $you'), - Text('Your shared key: $sharedKey'), + ClickableText( + leftText: 'Your shared key:', + clickableText: sharedKey!, + ), ], ), ), diff --git a/lib/features/order/notfiers/abstract_order_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart similarity index 85% rename from lib/features/order/notfiers/abstract_order_notifier.dart rename to lib/features/order/notfiers/abstract_mostro_notifier.dart index 43c84d63..7f706301 100644 --- a/lib/features/order/notfiers/abstract_order_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/config.dart'; @@ -7,47 +6,56 @@ import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/payload.dart'; import 'package:mostro_mobile/data/models/peer.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; -import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_providers.dart'; -class AbstractOrderNotifier extends StateNotifier { - final MostroService mostroService; - final Ref ref; +class AbstractMostroNotifier extends StateNotifier { final String orderId; - StreamSubscription? orderSubscription; + final Ref ref; + + ProviderSubscription>? subscription; final logger = Logger(); - AbstractOrderNotifier( - this.mostroService, + AbstractMostroNotifier( this.orderId, this.ref, ) : super(MostroMessage(action: Action.newOrder, id: orderId)); - Future subscribe(Stream stream) async { - try { - orderSubscription = stream.listen((event) { - if (event.payload is CantDo) { - handleCantDo(event.getPayload()); - return; - } - state = event; - handleOrderUpdate(); - }); - } catch (e) { - handleError(e); - } + Future sync() async { + final storage = ref.read(mostroStorageProvider); + state = await storage.getMessageById(orderId) ?? state; + } + + + void subscribe() { + subscription = ref.listen(sessionMessagesProvider(orderId), (_, next) { + next.when( + data: (msg) { + handleEvent(msg); + }, + error: (error, stack) => handleError(error, stack), + loading: () {}, + ); + }); } - void handleError(Object err) { + void handleError(Object err, StackTrace stack) { logger.e(err); } + void handleEvent(MostroMessage event) { + state = event; + handleOrderUpdate(); + } + void handleCantDo(CantDo? cantDo) { final notifProvider = ref.read(notificationProvider.notifier); notifProvider.showInformation(Action.cantDo, values: { @@ -175,7 +183,7 @@ class AbstractOrderNotifier extends StateNotifier { @override void dispose() { - orderSubscription?.cancel(); + subscription?.close(); super.dispose(); } } diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart index ca772ccb..b4a80074 100644 --- a/lib/features/order/notfiers/add_order_notifier.dart +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -1,44 +1,69 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; +import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; -class AddOrderNotifier extends AbstractOrderNotifier { - AddOrderNotifier(super.mostroService, super.orderId, super.ref); +class AddOrderNotifier extends AbstractMostroNotifier { + late final MostroService mostroService; + int? requestId; + + AddOrderNotifier(super.orderId, super.ref) { + mostroService = ref.read(mostroServiceProvider); + } @override - Future subscribe(Stream stream) async { - try { - orderSubscription = stream.listen((order) { - state = order; - if (order.action == Action.newOrder) { - confirmOrder(order); - } else { - handleOrderUpdate(); - } - }); - } catch (e) { - handleError(e); - } + void subscribe() { + subscription = ref.listen(addOrderEventsProvider(requestId!), (_, next) { + next.when( + data: (msg) { + if (msg.payload is Order) { + state = msg; + if (msg.action == Action.newOrder) { + confirmOrder(msg); + } + } else if (msg.payload is CantDo) { + _handleCantDo(msg); + } + }, + error: (error, stack) => handleError(error, stack), + loading: () {}, + ); + }); + } + + void _handleCantDo(MostroMessage message) { + final notifProvider = ref.read(notificationProvider.notifier); + final cantDo = message.getPayload(); + notifProvider.showInformation( + message.action, + values: { + 'action': cantDo?.cantDoReason.toString(), + }, + ); } // This method is called when the order is confirmed. Future confirmOrder(MostroMessage confirmedOrder) async { - // Extract the confirmed (real) order id. - final confirmedOrderId = confirmedOrder.id; - final newNotifier = - ref.read(orderNotifierProvider(confirmedOrderId!).notifier); + final orderNotifier = + ref.watch(orderNotifierProvider(confirmedOrder.id!).notifier); handleOrderUpdate(); - newNotifier.resubscribe(); + orderNotifier.subscribe(); dispose(); } Future submitOrder(Order order) async { - final requestId = BigInt.parse( - orderId.replaceAll('-', ''), - radix: 16, - ).toUnsigned(64).toInt(); + requestId = requestId ?? + BigInt.parse( + orderId.replaceAll('-', ''), + radix: 16, + ).toUnsigned(64).toInt(); final message = MostroMessage( action: Action.newOrder, @@ -46,8 +71,7 @@ class AddOrderNotifier extends AbstractOrderNotifier { requestId: requestId, payload: order, ); - final session = await mostroService.publishOrder(message); - final stream = mostroService.subscribe(session); - await subscribe(stream); + if (subscription == null) subscribe(); + await mostroService.submitOrder(message); } } diff --git a/lib/features/order/notfiers/cant_do_notifier.dart b/lib/features/order/notfiers/cant_do_notifier.dart new file mode 100644 index 00000000..48c0129f --- /dev/null +++ b/lib/features/order/notfiers/cant_do_notifier.dart @@ -0,0 +1,24 @@ +import 'package:mostro_mobile/data/models/cant_do.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; +import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; + +class CantDoNotifier extends AbstractMostroNotifier { + CantDoNotifier(super.orderId, super.ref) { + sync(); + subscribe(); + } + + @override + void handleEvent(MostroMessage event) { + if (event.payload is! CantDo) return; + + final cantDo = event.getPayload(); + + final notifProvider = ref.read(notificationProvider.notifier); + notifProvider.showInformation(Action.cantDo, values: { + 'action': cantDo?.cantDoReason.toString() ?? '', + }); + } +} diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 76eb043a..9c9a363d 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -2,20 +2,26 @@ import 'dart:async'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/features/order/notfiers/abstract_order_notifier.dart'; +import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; +import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; -class OrderNotifier extends AbstractOrderNotifier { - OrderNotifier(super.mostroService, super.orderId, super.ref); +class OrderNotifier extends AbstractMostroNotifier { + late final MostroService mostroService; - Future sync() async { - state = await mostroService.getOrderById(orderId) ?? state; + OrderNotifier(super.orderId, super.ref) { + mostroService = ref.read(mostroServiceProvider); + sync(); + subscribe(); } - Future resubscribe() async { - await sync(); - final session = mostroService.getSessionByOrderId(orderId); - final stream = mostroService.subscribe(session!); - await subscribe(stream); + + @override + void handleEvent(MostroMessage event) { + if (event.payload is Order || event.payload == null) { + state = event; + handleOrderUpdate(); + } } Future submitOrder(Order order) async { @@ -24,29 +30,23 @@ class OrderNotifier extends AbstractOrderNotifier { id: null, payload: order, ); - final session = await mostroService.publishOrder(message); - final stream = mostroService.subscribe(session); - await subscribe(stream); + await mostroService.submitOrder(message); } Future takeSellOrder( String orderId, int? amount, String? lnAddress) async { - final session = await mostroService.takeSellOrder( + await mostroService.takeSellOrder( orderId, amount, lnAddress, ); - final stream = mostroService.subscribe(session); - await subscribe(stream); } Future takeBuyOrder(String orderId, int? amount) async { - final session = await mostroService.takeBuyOrder( + await mostroService.takeBuyOrder( orderId, amount, ); - final stream = mostroService.subscribe(session); - await subscribe(stream); } Future sendInvoice(String orderId, String invoice, int? amount) async { diff --git a/lib/features/order/notfiers/payment_request_notifier.dart b/lib/features/order/notfiers/payment_request_notifier.dart new file mode 100644 index 00000000..487f5d74 --- /dev/null +++ b/lib/features/order/notfiers/payment_request_notifier.dart @@ -0,0 +1,18 @@ +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/payment_request.dart'; +import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; + +class PaymentRequestNotifier extends AbstractMostroNotifier { + PaymentRequestNotifier(super.orderId, super.ref) { + sync(); + subscribe(); + } + + @override + void handleEvent(MostroMessage event) { + if (event.payload is PaymentRequest) { + state = event; + handleOrderUpdate(); + } + } +} diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index 0ef8d8f6..1a5dfd40 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -3,14 +3,18 @@ import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/features/order/notfiers/add_order_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; -import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/features/order/notfiers/payment_request_notifier.dart'; +import 'package:mostro_mobile/services/event_bus.dart'; +import 'package:mostro_mobile/features/order/notfiers/cant_do_notifier.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +part 'order_notifier_provider.g.dart'; final orderNotifierProvider = StateNotifierProvider.family( - (ref, orderId,) { - final repo = ref.read(mostroServiceProvider); + (ref, orderId) { + ref.read(cantDoNotifierProvider(orderId)); + ref.read(paymentNotifierProvider(orderId)); return OrderNotifier( - repo, orderId, ref, ); @@ -20,10 +24,40 @@ final orderNotifierProvider = final addOrderNotifierProvider = StateNotifierProvider.family( (ref, orderId) { - final repo = ref.read(mostroServiceProvider); - return AddOrderNotifier(repo, orderId, ref); + return AddOrderNotifier( + orderId, + ref, + ); }, ); +final cantDoNotifierProvider = + StateNotifierProvider.family( + (ref, orderId) { + return CantDoNotifier(orderId, ref); + }, +); + +final paymentNotifierProvider = + StateNotifierProvider.family( + (ref, orderId) { + return PaymentRequestNotifier(orderId, ref); + }, +); + + // This provider tracks the currently selected OrderType tab -final orderTypeProvider = StateProvider((ref) => OrderType.sell); +@riverpod +class OrderTypeNotifier extends _$OrderTypeNotifier { + @override + OrderType build() => OrderType.sell; + + void set(OrderType value) => state = value; +} + +final addOrderEventsProvider = StreamProvider.family( + (ref, requestId) { + final bus = ref.watch(eventBusProvider); + return bus.stream.where((msg) => msg.requestId == requestId); + }, +); diff --git a/lib/features/order/providers/order_notifier_provider.g.dart b/lib/features/order/providers/order_notifier_provider.g.dart new file mode 100644 index 00000000..6fdeb268 --- /dev/null +++ b/lib/features/order/providers/order_notifier_provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'order_notifier_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$orderTypeNotifierHash() => r'8162563dcc33acb976a55b96da8bb59e5e7c5abf'; + +/// See also [OrderTypeNotifier]. +@ProviderFor(OrderTypeNotifier) +final orderTypeNotifierProvider = + AutoDisposeNotifierProvider.internal( + OrderTypeNotifier.new, + name: r'orderTypeNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$orderTypeNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$OrderTypeNotifier = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 683d941d..38571f42 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -37,7 +37,7 @@ class _AddOrderScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final orderType = ref.watch(orderTypeProvider); + final orderType = ref.watch(orderTypeNotifierProvider); return Scaffold( backgroundColor: AppTheme.dark1, @@ -125,7 +125,7 @@ class _AddOrderScreenState extends ConsumerState { return GestureDetector( onTap: () { // Update the local orderType state - ref.read(orderTypeProvider.notifier).state = type; + ref.read(orderTypeNotifierProvider.notifier).set(type); }, child: Container( padding: const EdgeInsets.symmetric(vertical: 12), diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index 734b9bbd..f7dc7645 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -22,7 +22,7 @@ class _PayLightningInvoiceScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final order = ref.read(orderNotifierProvider(widget.orderId)); + final order = ref.read(paymentNotifierProvider(widget.orderId)); final lnInvoice = order.getPayload()?.lnInvoice ?? ''; final orderNotifier = ref.read(orderNotifierProvider(widget.orderId).notifier); diff --git a/lib/features/order/screens/take_order_screen.dart b/lib/features/order/screens/take_order_screen.dart index 224cea07..b167035a 100644 --- a/lib/features/order/screens/take_order_screen.dart +++ b/lib/features/order/screens/take_order_screen.dart @@ -151,8 +151,9 @@ class TakeOrderScreen extends ConsumerWidget { Widget _buildActionButtons( BuildContext context, WidgetRef ref, NostrEvent order) { - final orderDetailsNotifier = - ref.read(orderNotifierProvider(order.orderId!).notifier); + final orderDetailsNotifier = ref.read( + orderNotifierProvider(order.orderId!).notifier, + ); return Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/features/order/widgets/order_app_bar.dart b/lib/features/order/widgets/order_app_bar.dart index 8f997d2d..e8cd4f46 100644 --- a/lib/features/order/widgets/order_app_bar.dart +++ b/lib/features/order/widgets/order_app_bar.dart @@ -19,7 +19,7 @@ class OrderAppBar extends StatelessWidget implements PreferredSizeWidget { HeroIcons.arrowLeft, color: AppTheme.cream1, ), - onPressed: () => context.go('/'), + onPressed: () => context.go('/order_book'), ), title: Text( title, diff --git a/lib/features/rate/rate_counterpart_screen.dart b/lib/features/rate/rate_counterpart_screen.dart index a5673628..ccc01f79 100644 --- a/lib/features/rate/rate_counterpart_screen.dart +++ b/lib/features/rate/rate_counterpart_screen.dart @@ -25,15 +25,12 @@ class _RateCounterpartScreenState extends ConsumerState { Future _submitRating() async { _logger.i('Rating submitted: $_rating'); - final orderNotifer = - ref.watch(orderNotifierProvider(widget.orderId).notifier); + final orderNotifer = ref.watch( + orderNotifierProvider(widget.orderId).notifier, + ); await orderNotifer.submitRating(_rating); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(S.of(context)!.rateReceived)), - ); - context.pop(); } diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 479e94f3..64e465b1 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -184,7 +184,7 @@ class TradeDetailScreen extends ConsumerWidget { /// Additional checks use `message.action` to refine which button to show. List _buildActionButtons( BuildContext context, WidgetRef ref, NostrEvent order) { - final message = ref.watch(orderNotifierProvider(orderId)); + final message = ref.watch(orderNotifierProvider(orderId)); final session = ref.watch(sessionProvider(orderId)); // The finite-state-machine approach: decide based on the order.status. diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index 4581229d..7b7270d6 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -13,6 +13,7 @@ import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; @@ -27,9 +28,10 @@ class MostroMessageDetail extends ConsumerWidget { // Retrieve the MostroMessage using the order's orderId final mostroMessage = ref.watch(orderNotifierProvider(order.orderId!)); final session = ref.watch(sessionProvider(order.orderId!)); + final action = ref.watch(orderActionNotifierProvider(order.orderId!)); // Map the action enum to the corresponding i10n string. String actionText; - switch (mostroMessage.action) { + switch (action) { case actions.Action.newOrder: final expHrs = ref.read(orderRepositoryProvider).mostroInstance?.expiration ?? @@ -95,10 +97,10 @@ class MostroMessageDetail extends ConsumerWidget { ); break; case actions.Action.fiatSentOk: - final payload = mostroMessage.getPayload(); + final payload = mostroMessage.getPayload(); actionText = session!.role == Role.buyer - ? S.of(context)!.fiatSentOkBuyer(payload!.publicKey) - : S.of(context)!.fiatSentOkSeller(payload!.publicKey); + ? S.of(context)!.fiatSentOkBuyer(payload!.sellerTradePubkey!) + : S.of(context)!.fiatSentOkSeller(payload!.buyerTradePubkey!); break; case actions.Action.released: actionText = S.of(context)!.released('{seller_npub}'); @@ -266,7 +268,7 @@ class MostroMessageDetail extends ConsumerWidget { style: AppTheme.theme.textTheme.bodyLarge, ), const SizedBox(height: 16), - Text('${order.status} - ${mostroMessage.action}'), + Text('${order.status} - $action'), ], ), ), diff --git a/lib/services/event_bus.dart b/lib/services/event_bus.dart new file mode 100644 index 00000000..8dc6744d --- /dev/null +++ b/lib/services/event_bus.dart @@ -0,0 +1,22 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +part 'event_bus.g.dart'; + +class EventBus { + final _controller = StreamController.broadcast(); + + Stream get stream => _controller.stream; + + void emit(MostroMessage message) => _controller.add(message); + + void dispose() => _controller.close(); +} + +@riverpod +EventBus eventBus(Ref ref) { + final bus = EventBus(); + // ref.onDispose(bus.dispose); + return bus; +} diff --git a/lib/services/event_bus.g.dart b/lib/services/event_bus.g.dart new file mode 100644 index 00000000..73a6084a --- /dev/null +++ b/lib/services/event_bus.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'event_bus.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$eventBusHash() => r'8e50dc963edbcc64d7f61e4054c5537782944acf'; + +/// See also [eventBus]. +@ProviderFor(eventBus) +final eventBusProvider = AutoDisposeProvider.internal( + eventBus, + name: r'eventBusProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$eventBusHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef EventBusRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 1a2f5a16..bf08372e 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,8 +1,8 @@ import 'dart:convert'; import 'package:dart_nostr/dart_nostr.dart'; -import 'package:logger/logger.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/amount.dart'; -import 'package:mostro_mobile/data/models/cant_do.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; @@ -12,146 +12,106 @@ import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/payment_request.dart'; import 'package:mostro_mobile/data/models/rating_user.dart'; import 'package:mostro_mobile/data/models/session.dart'; +import 'package:mostro_mobile/data/repositories/event_storage.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/services/event_bus.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; +import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; class MostroService { + final Ref ref; final NostrService _nostrService; final SessionNotifier _sessionNotifier; + final EventStorage _eventStorage; final MostroStorage _messageStorage; - final _logger = Logger(); - Settings _settings; - MostroService(this._nostrService, this._sessionNotifier, this._settings, - this._messageStorage); + final EventBus _bus; - Future getOrderById(String orderId) async { - return await _messageStorage.getOrderById(orderId); - } + Settings _settings; - Future sync(Session session) async { + MostroService( + this._sessionNotifier, + this._eventStorage, + this._bus, + this._messageStorage, + this.ref, + ) : _nostrService = ref.read(nostrServiceProvider), + _settings = ref.read(settingsProvider); + + void subscribe(Session session) { final filter = NostrFilter( kinds: [1059], p: [session.tradeKey.public], ); - final events = await _nostrService.fecthEvents(filter); - List orders = []; - final eventsCopy = List.from(events); - for (final event in eventsCopy) { + _nostrService.subscribeToEvents(filter).listen((event) async { + // The item has already beeen processed + if (await _eventStorage.hasItem(event.id!)) return; + // Store the event + await _eventStorage.putItem( + event.id!, + event, + ); + final decryptedEvent = await event.unWrap( session.tradeKey.private, ); - - if (decryptedEvent.content == null) { - _logger.i('Event ${decryptedEvent.id} content is null'); - continue; - } + if (decryptedEvent.content == null) return; final result = jsonDecode(decryptedEvent.content!); - - if (result is! List) { - _logger.e('Event content ${decryptedEvent.content} should be a List'); - continue; - } + if (result is! List) return; final msgMap = result[0]; - if (msgMap.containsKey('order')) { - final msg = MostroMessage.fromJson(msgMap['order']); - orders.add(msg); - } else if (msgMap.containsKey('cant-do')) { - //final msg = MostroMessage.fromJson(msgMap['cant-do']); - //orders.add(msg); - } else { - _logger.e('Result not found ${decryptedEvent.content}'); - } - } - - _messageStorage.addOrders(orders); - } - - Stream subscribe(Session session) { - final filter = NostrFilter( - kinds: [1059], - p: [session.tradeKey.public], - ); - return _nostrService.subscribeToEvents(filter).asyncMap((event) async { - _logger.i('Event received from Mostro: $event'); - - final decryptedEvent = await event.unWrap( - session.tradeKey.private, + final msg = MostroMessage.fromJson( + msgMap['order'] ?? msgMap['cant-do'], ); - // Check event content is not null - if (decryptedEvent.content == null) { - _logger.i('Event ${decryptedEvent.id} content is null'); - throw FormatException('Event ${decryptedEvent.id} content is null'); - } - - // Deserialize the message content: - final result = jsonDecode(decryptedEvent.content!); - - _logger.i('Decrypted Mostro event content: $result'); + ref.read(orderActionNotifierProvider(msg.id!).notifier).set(msg.action,); - // The result should be an array of two elements, the first being - // A MostroMessage or CantDo - if (result is! List) { - throw FormatException( - 'Event content ${decryptedEvent.content} should be a List'); + if (msg.action == actions.Action.canceled) { + await _messageStorage.deleteAllMessagesById(session.orderId!); + await _sessionNotifier.deleteSession(session.orderId!); + return; } - final msgMap = result[0]; - - if (msgMap.containsKey('order')) { - final msg = MostroMessage.fromJson(msgMap['order']); - - if (msg.action == actions.Action.canceled) { - await _sessionNotifier.deleteSession(session.orderId!); - return msg; - } + await _messageStorage.addMessage(msg); - if (session.orderId == null && msg.id != null) { - session.orderId = msg.id; - await _sessionNotifier.saveSession(session); - } - await _saveMessage(msg); - return msg; + if (session.orderId == null && msg.id != null) { + session.orderId = msg.id; + await _sessionNotifier.saveSession(session); } - if (msgMap.containsKey('cant-do')) { - final msg = MostroMessage.fromJson(msgMap['cant-do']); - final cantdo = msg.getPayload(); - _logger.e('Can\'t Do: ${cantdo?.cantDoReason}'); - return msg; - } - throw FormatException('Result not found ${decryptedEvent.content}'); + _bus.emit(msg); }); } - Future _saveMessage(MostroMessage message) async { - await _messageStorage.addOrder(message); - } - Session? getSessionByOrderId(String orderId) { return _sessionNotifier.getSessionByOrderId(orderId); } - Future takeBuyOrder(String orderId, int? amount) async { + Future submitOrder(MostroMessage order) async { + final session = await publishOrder(order); + subscribe(session); + } + + Future takeBuyOrder(String orderId, int? amount) async { final amt = amount != null ? Amount(amount: amount) : null; - return await publishOrder( + final session = await publishOrder( MostroMessage( action: Action.takeBuy, id: orderId, payload: amt, ), ); + subscribe(session); } - Future takeSellOrder( + Future takeSellOrder( String orderId, int? amount, String? lnAddress) async { final payload = lnAddress != null ? PaymentRequest( @@ -163,13 +123,15 @@ class MostroService { ? Amount(amount: amount) : null; - return await publishOrder( + final session = await publishOrder( MostroMessage( action: Action.takeSell, id: orderId, payload: payload, ), ); + + subscribe(session); } Future sendInvoice(String orderId, String invoice, int? amount) async { diff --git a/lib/shared/notifiers/order_action_notifier.dart b/lib/shared/notifiers/order_action_notifier.dart new file mode 100644 index 00000000..bdaf1a2f --- /dev/null +++ b/lib/shared/notifiers/order_action_notifier.dart @@ -0,0 +1,16 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; + +class OrderActionNotifier extends StateNotifier { + OrderActionNotifier({required this.orderId}) : super(Action.newOrder); + + final String orderId; + + void set(Action action) { + state = action; + } +} + +final orderActionNotifierProvider = StateNotifierProvider.family( + (ref, orderId) => OrderActionNotifier(orderId: orderId), +); diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index be2bc3ad..99ac7a68 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -73,8 +73,8 @@ class SessionNotifier extends StateNotifier> { if (orderId != null) { _sessions[orderId] = session; - // Persist it to DB - await _storage.putSession(session); + // Persist it to DB + await _storage.putSession(session); state = sessions; } else { state = [...sessions, session]; diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index eaae2085..7fea1a94 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -7,7 +7,9 @@ import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -29,13 +31,20 @@ final appInitializerProvider = FutureProvider((ref) async { mostroService.updateSettings(next); }); + final mostroStorage = ref.read(mostroStorageProvider); + for (final session in sessionManager.sessions) { if (session.orderId != null) { - await mostroService.sync(session); - final order = ref.watch( - orderNotifierProvider(session.orderId!).notifier, + final orderList = await mostroStorage.getMessagesForId(session.orderId!); + if (orderList.isNotEmpty) { + ref.read(orderActionNotifierProvider(session.orderId!).notifier).set( + orderList.last.action, + ); + } + ref.read( + orderNotifierProvider(session.orderId!), ); - order.resubscribe(); + mostroService.subscribe(session); } if (session.peer != null) { @@ -60,6 +69,6 @@ Future clearAppData(MostroStorage mostroStorage) async { logger.i("Shared Storage Cleared"); // 3) MostroStorage - mostroStorage.deleteAllOrders(); + mostroStorage.deleteAllMessages(); logger.i("Mostro Message Storage cleared"); } diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index bb49e26e..4724f5c0 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -1,16 +1,31 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/data/repositories/event_storage.dart'; +import 'package:mostro_mobile/services/event_bus.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; -import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +part 'mostro_service_provider.g.dart'; -final mostroServiceProvider = Provider((ref) { +@riverpod +EventStorage eventStorage(Ref ref) { + final db = ref.watch(mostroDatabaseProvider); + return EventStorage(db: db); +} + +@riverpod +MostroService mostroService(Ref ref) { final sessionStorage = ref.read(sessionNotifierProvider.notifier); - final nostrService = ref.read(nostrServiceProvider); - final settings = ref.read(settingsProvider); + final eventStore = ref.read(eventStorageProvider); + final eventBus = ref.read(eventBusProvider); final mostroDatabase = ref.read(mostroStorageProvider); - final mostroService = - MostroService(nostrService, sessionStorage, settings, mostroDatabase); + final mostroService = MostroService( + sessionStorage, + eventStore, + eventBus, + mostroDatabase, + ref + ); return mostroService; -}); +} diff --git a/lib/shared/providers/mostro_service_provider.g.dart b/lib/shared/providers/mostro_service_provider.g.dart new file mode 100644 index 00000000..34196ff7 --- /dev/null +++ b/lib/shared/providers/mostro_service_provider.g.dart @@ -0,0 +1,43 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mostro_service_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$eventStorageHash() => r'671aa58f4248e9d508ea22bcc85da2d19d36b0b6'; + +/// See also [eventStorage]. +@ProviderFor(eventStorage) +final eventStorageProvider = AutoDisposeProvider.internal( + eventStorage, + name: r'eventStorageProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$eventStorageHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef EventStorageRef = AutoDisposeProviderRef; +String _$mostroServiceHash() => r'6e27d58dbe7170c180dcf3729f0f5f27a29f06c6'; + +/// See also [mostroService]. +@ProviderFor(mostroService) +final mostroServiceProvider = AutoDisposeProvider.internal( + mostroService, + name: r'mostroServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$mostroServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef MostroServiceRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/shared/providers/session_providers.dart b/lib/shared/providers/session_providers.dart new file mode 100644 index 00000000..322c76f2 --- /dev/null +++ b/lib/shared/providers/session_providers.dart @@ -0,0 +1,45 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; +import 'package:mostro_mobile/services/event_bus.dart'; +import 'package:mostro_mobile/features/order/notfiers/cant_do_notifier.dart'; +import 'package:mostro_mobile/features/order/notfiers/payment_request_notifier.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +part 'session_providers.g.dart'; + +class SessionProviders { + final String orderId; + final OrderNotifier orderNotifier; + final PaymentRequestNotifier paymentRequestNotifier; + final CantDoNotifier cantDoNotifier; + + SessionProviders({ + required this.orderId, + required Ref ref, + }) : orderNotifier = OrderNotifier(orderId, ref), + paymentRequestNotifier = PaymentRequestNotifier(orderId, ref), + cantDoNotifier = CantDoNotifier(orderId, ref); + + void dispose() { + orderNotifier.dispose(); + paymentRequestNotifier.dispose(); + cantDoNotifier.dispose(); + } +} + +@riverpod +class SessionMessages extends _$SessionMessages { + @override + Stream build(String orderId) { + final bus = ref.watch(eventBusProvider); + return bus.stream.where((msg) => msg.id == orderId); + } +} + +@riverpod +SessionProviders sessionProviders(Ref ref, String orderId) { + final providers = SessionProviders(orderId: orderId, ref: ref); + //ref.onDispose(providers.dispose); + return providers; +} + diff --git a/lib/shared/providers/session_providers.g.dart b/lib/shared/providers/session_providers.g.dart new file mode 100644 index 00000000..e0724638 --- /dev/null +++ b/lib/shared/providers/session_providers.g.dart @@ -0,0 +1,308 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'session_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$sessionProvidersHash() => r'7756994a4a75d695f0ea3377c0a3158155a46e50'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [sessionProviders]. +@ProviderFor(sessionProviders) +const sessionProvidersProvider = SessionProvidersFamily(); + +/// See also [sessionProviders]. +class SessionProvidersFamily extends Family { + /// See also [sessionProviders]. + const SessionProvidersFamily(); + + /// See also [sessionProviders]. + SessionProvidersProvider call( + String orderId, + ) { + return SessionProvidersProvider( + orderId, + ); + } + + @override + SessionProvidersProvider getProviderOverride( + covariant SessionProvidersProvider provider, + ) { + return call( + provider.orderId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'sessionProvidersProvider'; +} + +/// See also [sessionProviders]. +class SessionProvidersProvider extends AutoDisposeProvider { + /// See also [sessionProviders]. + SessionProvidersProvider( + String orderId, + ) : this._internal( + (ref) => sessionProviders( + ref as SessionProvidersRef, + orderId, + ), + from: sessionProvidersProvider, + name: r'sessionProvidersProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$sessionProvidersHash, + dependencies: SessionProvidersFamily._dependencies, + allTransitiveDependencies: + SessionProvidersFamily._allTransitiveDependencies, + orderId: orderId, + ); + + SessionProvidersProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.orderId, + }) : super.internal(); + + final String orderId; + + @override + Override overrideWith( + SessionProviders Function(SessionProvidersRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: SessionProvidersProvider._internal( + (ref) => create(ref as SessionProvidersRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + orderId: orderId, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _SessionProvidersProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SessionProvidersProvider && other.orderId == orderId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, orderId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SessionProvidersRef on AutoDisposeProviderRef { + /// The parameter `orderId` of this provider. + String get orderId; +} + +class _SessionProvidersProviderElement + extends AutoDisposeProviderElement + with SessionProvidersRef { + _SessionProvidersProviderElement(super.provider); + + @override + String get orderId => (origin as SessionProvidersProvider).orderId; +} + +String _$sessionMessagesHash() => r'a8f6f4e0f8ec58f745b1e1acd68651436706d948'; + +abstract class _$SessionMessages + extends BuildlessAutoDisposeStreamNotifier { + late final String orderId; + + Stream build( + String orderId, + ); +} + +/// See also [SessionMessages]. +@ProviderFor(SessionMessages) +const sessionMessagesProvider = SessionMessagesFamily(); + +/// See also [SessionMessages]. +class SessionMessagesFamily extends Family> { + /// See also [SessionMessages]. + const SessionMessagesFamily(); + + /// See also [SessionMessages]. + SessionMessagesProvider call( + String orderId, + ) { + return SessionMessagesProvider( + orderId, + ); + } + + @override + SessionMessagesProvider getProviderOverride( + covariant SessionMessagesProvider provider, + ) { + return call( + provider.orderId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'sessionMessagesProvider'; +} + +/// See also [SessionMessages]. +class SessionMessagesProvider extends AutoDisposeStreamNotifierProviderImpl< + SessionMessages, MostroMessage> { + /// See also [SessionMessages]. + SessionMessagesProvider( + String orderId, + ) : this._internal( + () => SessionMessages()..orderId = orderId, + from: sessionMessagesProvider, + name: r'sessionMessagesProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$sessionMessagesHash, + dependencies: SessionMessagesFamily._dependencies, + allTransitiveDependencies: + SessionMessagesFamily._allTransitiveDependencies, + orderId: orderId, + ); + + SessionMessagesProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.orderId, + }) : super.internal(); + + final String orderId; + + @override + Stream runNotifierBuild( + covariant SessionMessages notifier, + ) { + return notifier.build( + orderId, + ); + } + + @override + Override overrideWith(SessionMessages Function() create) { + return ProviderOverride( + origin: this, + override: SessionMessagesProvider._internal( + () => create()..orderId = orderId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + orderId: orderId, + ), + ); + } + + @override + AutoDisposeStreamNotifierProviderElement + createElement() { + return _SessionMessagesProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SessionMessagesProvider && other.orderId == orderId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, orderId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SessionMessagesRef + on AutoDisposeStreamNotifierProviderRef { + /// The parameter `orderId` of this provider. + String get orderId; +} + +class _SessionMessagesProviderElement + extends AutoDisposeStreamNotifierProviderElement with SessionMessagesRef { + _SessionMessagesProviderElement(super.provider); + + @override + String get orderId => (origin as SessionMessagesProvider).orderId; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/shared/widgets/add_lightning_invoice_widget.dart b/lib/shared/widgets/add_lightning_invoice_widget.dart index 89b64232..be323dba 100644 --- a/lib/shared/widgets/add_lightning_invoice_widget.dart +++ b/lib/shared/widgets/add_lightning_invoice_widget.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/shared/widgets/clickable_amount_widget.dart'; +import 'package:mostro_mobile/shared/widgets/clickable_text_widget.dart'; class AddLightningInvoiceWidget extends StatefulWidget { final TextEditingController controller; @@ -27,9 +27,9 @@ class _AddLightningInvoiceWidgetState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClickableAmountText( + ClickableText( leftText: 'Please enter a Lightning Invoice for: ', - amount: '${widget.amount}', + clickableText: '${widget.amount}', rightText: ' sats', ), const SizedBox(height: 16), diff --git a/lib/shared/widgets/clickable_amount_widget.dart b/lib/shared/widgets/clickable_text_widget.dart similarity index 65% rename from lib/shared/widgets/clickable_amount_widget.dart rename to lib/shared/widgets/clickable_text_widget.dart index 4787e1ae..67de04a3 100644 --- a/lib/shared/widgets/clickable_amount_widget.dart +++ b/lib/shared/widgets/clickable_text_widget.dart @@ -3,23 +3,23 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -class ClickableAmountText extends StatefulWidget { +class ClickableText extends StatefulWidget { final String leftText; - final String amount; - final String rightText; + final String clickableText; + final String? rightText; - const ClickableAmountText({ + const ClickableText({ super.key, required this.leftText, - required this.amount, - required this.rightText, + required this.clickableText, + this.rightText, }); @override - State createState() => _ClickableAmountTextState(); + State createState() => _ClickableTextState(); } -class _ClickableAmountTextState extends State { +class _ClickableTextState extends State { late TapGestureRecognizer _tapRecognizer; @override @@ -35,13 +35,13 @@ class _ClickableAmountTextState extends State { } void _handleTap() async { - await Clipboard.setData(ClipboardData(text: widget.amount)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Amount ${widget.amount} copied to clipboard'), + content: Text('${widget.leftText} ${widget.clickableText} copied to clipboard'), duration: const Duration(seconds: 2), ), ); + await Clipboard.setData(ClipboardData(text: widget.clickableText)); } @override @@ -52,16 +52,16 @@ class _ClickableAmountTextState extends State { .style .copyWith(fontSize: 16, color: AppTheme.cream1), children: [ - TextSpan(text: widget.leftText), + TextSpan(text: '${widget.leftText} '), TextSpan( - text: widget.amount, + text: widget.clickableText, style: const TextStyle( color: Colors.blue, decoration: TextDecoration.underline, ), recognizer: _tapRecognizer, ), - TextSpan(text: widget.rightText), + if (widget.rightText != null) TextSpan(text: widget.rightText), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index 5e98ce0d..dd62c244 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.3.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4 + url: "https://pub.dev" + source: hosted + version: "0.13.0" archive: dependency: transitive description: @@ -273,6 +281,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.dev" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.3.0" dart_flutter_team_lints: dependency: transitive description: @@ -538,6 +562,14 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b + url: "https://pub.dev" + source: hosted + version: "3.0.0" frontend_server_client: dependency: transitive description: @@ -1021,6 +1053,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611" + url: "https://pub.dev" + source: hosted + version: "0.5.10" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36" + url: "https://pub.dev" + source: hosted + version: "2.6.5" sembast: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 928a5ae5..e8c4f5bc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,10 @@ dependencies: path: ^1.9.0 sembast: ^3.8.2 sembast_web: ^2.4.1 + circular_countdown: ^2.1.0 + line_icons: ^2.0.3 + introduction_screen: ^3.1.17 + riverpod_annotation: ^2.6.1 flutter_localizations: sdk: flutter @@ -75,9 +79,6 @@ dependencies: ref: dart3a path: app_sembast version: '>=0.1.0' - circular_countdown: ^2.1.0 - line_icons: ^2.0.3 - introduction_screen: ^3.1.17 dev_dependencies: flutter_test: @@ -95,6 +96,7 @@ dev_dependencies: flutter_intl: ^0.0.1 mockito: ^5.4.4 build_runner: ^2.4.0 + riverpod_generator: # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/mocks.dart b/test/mocks.dart index b83ac088..b64e487c 100644 --- a/test/mocks.dart +++ b/test/mocks.dart @@ -1,7 +1,6 @@ import 'package:mockito/annotations.dart'; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; -import 'package:mostro_mobile/data/repositories/mostro_repository.dart'; -@GenerateMocks([MostroService, MostroRepository, OpenOrdersRepository]) +@GenerateMocks([MostroService, OpenOrdersRepository]) void main() {} diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index 08e31e37..de227f6f 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -5,14 +5,14 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; -import 'package:dart_nostr/dart_nostr.dart' as _i3; +import 'package:dart_nostr/dart_nostr.dart' as _i10; +import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; import 'package:mostro_mobile/data/models/mostro_message.dart' as _i6; import 'package:mostro_mobile/data/models/payload.dart' as _i7; -import 'package:mostro_mobile/data/models/session.dart' as _i2; -import 'package:mostro_mobile/data/repositories/mostro_repository.dart' as _i9; +import 'package:mostro_mobile/data/models/session.dart' as _i3; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart' - as _i10; + as _i9; import 'package:mostro_mobile/features/settings/settings.dart' as _i8; import 'package:mostro_mobile/services/mostro_service.dart' as _i4; @@ -30,13 +30,14 @@ import 'package:mostro_mobile/services/mostro_service.dart' as _i4; // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class -class _FakeSession_0 extends _i1.SmartFake implements _i2.Session { - _FakeSession_0(Object parent, Invocation parentInvocation) +class _FakeRef_0 extends _i1.SmartFake + implements _i2.Ref { + _FakeRef_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeNostrEvent_1 extends _i1.SmartFake implements _i3.NostrEvent { - _FakeNostrEvent_1(Object parent, Invocation parentInvocation) +class _FakeSession_1 extends _i1.SmartFake implements _i3.Session { + _FakeSession_1(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } @@ -49,197 +50,54 @@ class MockMostroService extends _i1.Mock implements _i4.MostroService { } @override - _i5.Stream<_i6.MostroMessage<_i7.Payload>> subscribe(_i2.Session? session) => + _i2.Ref get ref => (super.noSuchMethod( - Invocation.method(#subscribe, [session]), - returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), + Invocation.getter(#ref), + returnValue: _FakeRef_0(this, Invocation.getter(#ref)), ) - as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); + as _i2.Ref); @override - _i2.Session? getSessionByOrderId(String? orderId) => - (super.noSuchMethod(Invocation.method(#getSessionByOrderId, [orderId])) - as _i2.Session?); + void subscribe(_i3.Session? session) => super.noSuchMethod( + Invocation.method(#subscribe, [session]), + returnValueForMissingStub: null, + ); @override - _i5.Future<_i2.Session> takeSellOrder( - String? orderId, - int? amount, - String? lnAddress, - ) => - (super.noSuchMethod( - Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), - returnValue: _i5.Future<_i2.Session>.value( - _FakeSession_0( - this, - Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), - ), - ), - ) - as _i5.Future<_i2.Session>); + _i3.Session? getSessionByOrderId(String? orderId) => + (super.noSuchMethod(Invocation.method(#getSessionByOrderId, [orderId])) + as _i3.Session?); @override - _i5.Future sendInvoice(String? orderId, String? invoice, int? amount) => + _i5.Future submitOrder(_i6.MostroMessage<_i7.Payload>? order) => (super.noSuchMethod( - Invocation.method(#sendInvoice, [orderId, invoice, amount]), + Invocation.method(#submitOrder, [order]), returnValue: _i5.Future.value(), returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future<_i2.Session> takeBuyOrder(String? orderId, int? amount) => + _i5.Future takeBuyOrder(String? orderId, int? amount) => (super.noSuchMethod( Invocation.method(#takeBuyOrder, [orderId, amount]), - returnValue: _i5.Future<_i2.Session>.value( - _FakeSession_0( - this, - Invocation.method(#takeBuyOrder, [orderId, amount]), - ), - ), - ) - as _i5.Future<_i2.Session>); - - @override - _i5.Future cancelOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#cancelOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future sendFiatSent(String? orderId) => - (super.noSuchMethod( - Invocation.method(#sendFiatSent, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future releaseOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#releaseOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future disputeOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#disputeOrder, [orderId]), returnValue: _i5.Future.value(), returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future<_i2.Session> publishOrder(_i6.MostroMessage<_i7.Payload>? order) => - (super.noSuchMethod( - Invocation.method(#publishOrder, [order]), - returnValue: _i5.Future<_i2.Session>.value( - _FakeSession_0(this, Invocation.method(#publishOrder, [order])), - ), - ) - as _i5.Future<_i2.Session>); - - @override - _i5.Future<_i3.NostrEvent> createNIP59Event( - String? content, - String? recipientPubKey, - _i2.Session? session, - ) => - (super.noSuchMethod( - Invocation.method(#createNIP59Event, [ - content, - recipientPubKey, - session, - ]), - returnValue: _i5.Future<_i3.NostrEvent>.value( - _FakeNostrEvent_1( - this, - Invocation.method(#createNIP59Event, [ - content, - recipientPubKey, - session, - ]), - ), - ), - ) - as _i5.Future<_i3.NostrEvent>); - - @override - void updateSettings(_i8.Settings? settings) => super.noSuchMethod( - Invocation.method(#updateSettings, [settings]), - returnValueForMissingStub: null, - ); -} - -/// A class which mocks [MostroRepository]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { - MockMostroRepository() { - _i1.throwOnMissingStub(this); - } - - @override - List<_i6.MostroMessage<_i7.Payload>> get allMessages => - (super.noSuchMethod( - Invocation.getter(#allMessages), - returnValue: <_i6.MostroMessage<_i7.Payload>>[], - ) - as List<_i6.MostroMessage<_i7.Payload>>); - - @override - _i5.Future<_i6.MostroMessage<_i7.Payload>?> getOrderById(String? orderId) => - (super.noSuchMethod( - Invocation.method(#getOrderById, [orderId]), - returnValue: _i5.Future<_i6.MostroMessage<_i7.Payload>?>.value(), - ) - as _i5.Future<_i6.MostroMessage<_i7.Payload>?>); - - @override - _i5.Stream<_i6.MostroMessage<_i7.Payload>> resubscribeOrder( - String? orderId, - ) => - (super.noSuchMethod( - Invocation.method(#resubscribeOrder, [orderId]), - returnValue: _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ) - as _i5.Stream<_i6.MostroMessage<_i7.Payload>>); - - @override - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> takeSellOrder( + _i5.Future takeSellOrder( String? orderId, int? amount, String? lnAddress, ) => (super.noSuchMethod( Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ), - ) - as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); - - @override - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> takeBuyOrder( - String? orderId, - int? amount, - ) => - (super.noSuchMethod( - Invocation.method(#takeBuyOrder, [orderId, amount]), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); + as _i5.Future); @override _i5.Future sendInvoice(String? orderId, String? invoice, int? amount) => @@ -250,19 +108,6 @@ class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { ) as _i5.Future); - @override - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>> publishOrder( - _i6.MostroMessage<_i7.Payload>? order, - ) => - (super.noSuchMethod( - Invocation.method(#publishOrder, [order]), - returnValue: - _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>.value( - _i5.Stream<_i6.MostroMessage<_i7.Payload>>.empty(), - ), - ) - as _i5.Future<_i5.Stream<_i6.MostroMessage<_i7.Payload>>>); - @override _i5.Future cancelOrder(String? orderId) => (super.noSuchMethod( @@ -273,128 +118,74 @@ class MockMostroRepository extends _i1.Mock implements _i9.MostroRepository { as _i5.Future); @override - _i5.Future saveMessages() => - (super.noSuchMethod( - Invocation.method(#saveMessages, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future saveMessage(_i6.MostroMessage<_i7.Payload>? message) => - (super.noSuchMethod( - Invocation.method(#saveMessage, [message]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future deleteMessage(String? messageId) => + _i5.Future sendFiatSent(String? orderId) => (super.noSuchMethod( - Invocation.method(#deleteMessage, [messageId]), + Invocation.method(#sendFiatSent, [orderId]), returnValue: _i5.Future.value(), returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future loadMessages() => + _i5.Future releaseOrder(String? orderId) => (super.noSuchMethod( - Invocation.method(#loadMessages, []), + Invocation.method(#releaseOrder, [orderId]), returnValue: _i5.Future.value(), returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); - - @override - _i5.Future addOrder(_i6.MostroMessage<_i7.Payload>? order) => + _i5.Future disputeOrder(String? orderId) => (super.noSuchMethod( - Invocation.method(#addOrder, [order]), + Invocation.method(#disputeOrder, [orderId]), returnValue: _i5.Future.value(), returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future deleteOrder(String? orderId) => + _i5.Future submitRating(String? orderId, int? rating) => (super.noSuchMethod( - Invocation.method(#deleteOrder, [orderId]), + Invocation.method(#submitRating, [orderId, rating]), returnValue: _i5.Future.value(), returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); @override - _i5.Future>> getAllOrders() => + _i5.Future<_i3.Session> publishOrder(_i6.MostroMessage<_i7.Payload>? order) => (super.noSuchMethod( - Invocation.method(#getAllOrders, []), - returnValue: _i5.Future>>.value( - <_i6.MostroMessage<_i7.Payload>>[], + Invocation.method(#publishOrder, [order]), + returnValue: _i5.Future<_i3.Session>.value( + _FakeSession_1(this, Invocation.method(#publishOrder, [order])), ), ) - as _i5.Future>>); - - @override - _i5.Future updateOrder(_i6.MostroMessage<_i7.Payload>? order) => - (super.noSuchMethod( - Invocation.method(#updateOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + as _i5.Future<_i3.Session>); @override - _i5.Future sendFiatSent(String? orderId) => - (super.noSuchMethod( - Invocation.method(#sendFiatSent, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future releaseOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#releaseOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); - - @override - _i5.Future disputeOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#disputeOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + void updateSettings(_i8.Settings? settings) => super.noSuchMethod( + Invocation.method(#updateSettings, [settings]), + returnValueForMissingStub: null, + ); } /// A class which mocks [OpenOrdersRepository]. /// /// See the documentation for Mockito's code generation for more information. class MockOpenOrdersRepository extends _i1.Mock - implements _i10.OpenOrdersRepository { + implements _i9.OpenOrdersRepository { MockOpenOrdersRepository() { _i1.throwOnMissingStub(this); } @override - _i5.Stream> get eventsStream => + _i5.Stream> get eventsStream => (super.noSuchMethod( Invocation.getter(#eventsStream), - returnValue: _i5.Stream>.empty(), + returnValue: _i5.Stream>.empty(), ) - as _i5.Stream>); + as _i5.Stream>); @override void dispose() => super.noSuchMethod( @@ -403,15 +194,15 @@ class MockOpenOrdersRepository extends _i1.Mock ); @override - _i5.Future<_i3.NostrEvent?> getOrderById(String? orderId) => + _i5.Future<_i10.NostrEvent?> getOrderById(String? orderId) => (super.noSuchMethod( Invocation.method(#getOrderById, [orderId]), - returnValue: _i5.Future<_i3.NostrEvent?>.value(), + returnValue: _i5.Future<_i10.NostrEvent?>.value(), ) - as _i5.Future<_i3.NostrEvent?>); + as _i5.Future<_i10.NostrEvent?>); @override - _i5.Future addOrder(_i3.NostrEvent? order) => + _i5.Future addOrder(_i10.NostrEvent? order) => (super.noSuchMethod( Invocation.method(#addOrder, [order]), returnValue: _i5.Future.value(), @@ -429,17 +220,17 @@ class MockOpenOrdersRepository extends _i1.Mock as _i5.Future); @override - _i5.Future> getAllOrders() => + _i5.Future> getAllOrders() => (super.noSuchMethod( Invocation.method(#getAllOrders, []), - returnValue: _i5.Future>.value( - <_i3.NostrEvent>[], + returnValue: _i5.Future>.value( + <_i10.NostrEvent>[], ), ) - as _i5.Future>); + as _i5.Future>); @override - _i5.Future updateOrder(_i3.NostrEvent? order) => + _i5.Future updateOrder(_i10.NostrEvent? order) => (super.noSuchMethod( Invocation.method(#updateOrder, [order]), returnValue: _i5.Future.value(), diff --git a/test/notifiers/add_order_notifier_test.dart b/test/notifiers/add_order_notifier_test.dart index 5530298a..c7855eb3 100644 --- a/test/notifiers/add_order_notifier_test.dart +++ b/test/notifiers/add_order_notifier_test.dart @@ -17,14 +17,14 @@ void main() { group('AddOrderNotifier - Mockito tests', () { late ProviderContainer container; - late MockMostroService mockRepository; + late MockMostroService mockMostroService; late MockOpenOrdersRepository mockOrdersRepository; const testUuid = "test_uuid"; setUp(() { container = ProviderContainer(); - mockRepository = MockMostroService(); + mockMostroService = MockMostroService(); mockOrdersRepository = MockOpenOrdersRepository(); }); @@ -36,7 +36,7 @@ void main() { /// called, it returns a Stream based on `confirmationJson`. void configureMockPublishOrder(Map confirmationJson) { final confirmationMessage = MostroMessage.fromJson(confirmationJson); - when(mockRepository.publishOrder(any)).thenAnswer((invocation) async { + when(mockMostroService.publishOrder(any)).thenAnswer((invocation) async { // Return a stream that emits the confirmation message once. return Stream.value(confirmationMessage); }); @@ -70,7 +70,7 @@ void main() { // Override the repository provider with our mock. container = ProviderContainer(overrides: [ - mostroServiceProvider.overrideWithValue(mockRepository), + mostroServiceProvider.overrideWithValue(mockMostroService), orderRepositoryProvider.overrideWithValue(mockOrdersRepository), ]); @@ -107,7 +107,7 @@ void main() { expect(confirmedOrder.createdAt, equals(0)); // Optionally verify that publishOrder was called exactly once. - verify(mockRepository.publishOrder(any)).called(1); + verify(mockMostroService.publishOrder(any)).called(1); }); test('New Sell Range Order', () async { @@ -136,7 +136,7 @@ void main() { configureMockPublishOrder(confirmationJsonSellRange); container = ProviderContainer(overrides: [ - mostroRepositoryProvider.overrideWithValue(mockRepository), + mostroRepositoryProvider.overrideWithValue(mockMostroService), orderRepositoryProvider.overrideWithValue(mockOrdersRepository), ]); @@ -170,7 +170,7 @@ void main() { expect(confirmedOrder.paymentMethod, equals('face to face')); expect(confirmedOrder.premium, equals(1)); - verify(mockRepository.publishOrder(any)).called(1); + verify(mockMostroService.publishOrder(any)).called(1); }); test('New Buy Order', () async { @@ -200,7 +200,7 @@ void main() { configureMockPublishOrder(confirmationJsonBuy); container = ProviderContainer(overrides: [ - mostroRepositoryProvider.overrideWithValue(mockRepository), + mostroRepositoryProvider.overrideWithValue(mockMostroService), orderRepositoryProvider.overrideWithValue(mockOrdersRepository), ]); @@ -231,7 +231,7 @@ void main() { expect(confirmedOrder.premium, equals(1)); expect(confirmedOrder.buyerInvoice, isNull); - verify(mockRepository.publishOrder(any)).called(1); + verify(mockMostroService.publishOrder(any)).called(1); }); test('New Buy Order with Lightning Address', () async { @@ -261,7 +261,7 @@ void main() { configureMockPublishOrder(confirmationJsonBuyInvoice); container = ProviderContainer(overrides: [ - mostroRepositoryProvider.overrideWithValue(mockRepository), + mostroRepositoryProvider.overrideWithValue(mockMostroService), orderRepositoryProvider.overrideWithValue(mockOrdersRepository), ]); @@ -292,7 +292,7 @@ void main() { expect(confirmedOrder.premium, equals(1)); expect(confirmedOrder.buyerInvoice, equals('mostro_p2p@ln.tips')); - verify(mockRepository.publishOrder(any)).called(1); + verify(mockMostroService.publishOrder(any)).called(1); }); }); } diff --git a/test/notifiers/take_order_notifier_test.dart b/test/notifiers/take_order_notifier_test.dart index c796a359..71a1d541 100644 --- a/test/notifiers/take_order_notifier_test.dart +++ b/test/notifiers/take_order_notifier_test.dart @@ -15,12 +15,12 @@ void main() { group('Take Order Notifiers - Mockito tests', () { late ProviderContainer container; - late MockMostroRepository mockRepository; + late MockMostroService mockMostroService; const testOrderId = "test_order_id"; setUp(() { // Create a new instance of the mock repository. - mockRepository = MockMostroRepository(); + mockMostroService = MockMostroService(); }); @@ -70,14 +70,14 @@ void main() { }; // Stub the repository’s takeBuyOrder method. - when(mockRepository.takeBuyOrder(any, any)).thenAnswer((_) async { + when(mockMostroService.takeBuyOrder(any, any)).thenAnswer((_) async { final msg = MostroMessage.fromJson(confirmationJsonTakeBuy); return Stream.value(msg); }); // Override the repository provider with our mock. container = ProviderContainer(overrides: [ - mostroRepositoryProvider.overrideWithValue(mockRepository), + mostroServiceProvider.overrideWithValue(mockMostroService), ]); // Retrieve the notifier from the provider. @@ -93,7 +93,7 @@ void main() { // We expect the confirmation action to be "pay-invoice". expect(state.action, equals(Action.payInvoice)); // Optionally verify that the repository method was called. - verify(mockRepository.takeBuyOrder(testOrderId, any)).called(1); + verify(mockMostroService.takeBuyOrder(testOrderId, any)).called(1); }); test('Taking a Sell Order (fixed) - buyer sends take-sell and receives add-invoice confirmation', () async { @@ -118,14 +118,14 @@ void main() { } }; - when(mockRepository.takeSellOrder(any, any, any)).thenAnswer((_) async { + when(mockMostroService.takeSellOrder(any, any, any)).thenAnswer((_) async { final msg = MostroMessage.fromJson(confirmationJsonTakeSell); return Stream.value(msg); }); // Override the repository provider with our mock. container = ProviderContainer(overrides: [ - mostroRepositoryProvider.overrideWithValue(mockRepository), + mostroServiceProvider.overrideWithValue(mockMostroService), ]); final takeSellNotifier = @@ -145,7 +145,7 @@ void main() { expect(orderPayload.paymentMethod, equals('face to face')); expect(orderPayload.premium, equals(1)); - verify(mockRepository.takeSellOrder(testOrderId, any, any)).called(1); + verify(mockMostroService.takeSellOrder(testOrderId, any, any)).called(1); }); test('Taking a Sell Range Order - buyer sends take-sell with range payload', () async { @@ -172,14 +172,14 @@ void main() { } }; - when(mockRepository.takeSellOrder(any, any, any)).thenAnswer((_) async { + when(mockMostroService.takeSellOrder(any, any, any)).thenAnswer((_) async { final msg = MostroMessage.fromJson(confirmationJsonSellRange); return Stream.value(msg); }); // Override the repository provider with our mock. container = ProviderContainer(overrides: [ - mostroRepositoryProvider.overrideWithValue(mockRepository), + mostroServiceProvider.overrideWithValue(mockMostroService), ]); final takeSellNotifier = @@ -197,7 +197,7 @@ void main() { expect(orderPayload.maxAmount, equals(20)); expect(orderPayload.fiatAmount, equals(15)); - verify(mockRepository.takeSellOrder(testOrderId, any, any)).called(1); + verify(mockMostroService.takeSellOrder(testOrderId, any, any)).called(1); }); test('Taking a Sell Order with Lightning Address - buyer sends take-sell with LN address', () async { @@ -210,14 +210,14 @@ void main() { } }; - when(mockRepository.takeSellOrder(any, any, any)).thenAnswer((_) async { + when(mockMostroService.takeSellOrder(any, any, any)).thenAnswer((_) async { final msg = MostroMessage.fromJson(confirmationJsonSellLN); return Stream.value(msg); }); // Override the repository provider with our mock. container = ProviderContainer(overrides: [ - mostroRepositoryProvider.overrideWithValue(mockRepository), + mostroServiceProvider.overrideWithValue(mockMostroService), ]); final takeSellNotifier = @@ -230,7 +230,7 @@ void main() { expect(state, isNotNull); expect(state.action, equals(Action.waitingSellerToPay)); - verify(mockRepository.takeSellOrder(testOrderId, any, any)).called(1); + verify(mockMostroService.takeSellOrder(testOrderId, any, any)).called(1); }); }); diff --git a/test/services/mostro_service_test.dart b/test/services/mostro_service_test.dart index 560b8a64..1950e43a 100644 --- a/test/services/mostro_service_test.dart +++ b/test/services/mostro_service_test.dart @@ -6,7 +6,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/data/repositories/session_manager.dart'; +import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_derivator.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; @@ -17,22 +17,26 @@ import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; import 'mostro_service_test.mocks.dart'; import 'mostro_service_helper_functions.dart'; -@GenerateMocks([NostrService, SessionManager, SessionNotifier]) +@GenerateMocks([NostrService, SessionNotifier, MostroStorage]) void main() { late MostroService mostroService; late KeyDerivator keyDerivator; late MockNostrService mockNostrService; - late MockSessionManager mockSessionManager; late MockSessionNotifier mockSessionNotifier; + late MockMostroStorage mockSessionStorage; final mockServerTradeIndex = MockServerTradeIndex(); setUp(() { mockNostrService = MockNostrService(); - mockSessionManager = MockSessionManager(); mockSessionNotifier = MockSessionNotifier(); - mostroService = MostroService(mockNostrService, mockSessionNotifier, - Settings(relays: [], fullPrivacyMode: true, mostroPublicKey: 'xxx')); + mockSessionStorage = MockMostroStorage(); + mostroService = MostroService( + mockNostrService, + mockSessionNotifier, + Settings(relays: [], fullPrivacyMode: true, mostroPublicKey: 'xxx'), + mockSessionStorage, + ); keyDerivator = KeyDerivator("m/44'/1237'/38383'/0"); }); @@ -85,7 +89,8 @@ void main() { fullPrivacy: false, ); - when(mockSessionManager.getSessionByOrderId(orderId)).thenReturn(session); + when(mockSessionNotifier.getSessionByOrderId(orderId)) + .thenReturn(session); // Mock NostrService's createRumor, createSeal, createWrap, publishEvent when(mockNostrService.createRumor(any, any, any, any)) @@ -113,7 +118,7 @@ void main() { when(mockNostrService.publishEvent(any)) .thenAnswer((_) async => Future.value()); - when(mockSessionManager.newSession(orderId: orderId)) + when(mockSessionNotifier.newSession(orderId: orderId)) .thenAnswer((_) async => session); // Act @@ -164,7 +169,8 @@ void main() { fullPrivacy: false, ); - when(mockSessionManager.getSessionByOrderId(orderId)).thenReturn(session); + when(mockSessionNotifier.getSessionByOrderId(orderId)) + .thenReturn(session); // Mock NostrService's createRumor, createSeal, createWrap, publishEvent when(mockNostrService.createRumor(any, any, any, any)) @@ -239,7 +245,8 @@ void main() { fullPrivacy: false, ); - when(mockSessionManager.getSessionByOrderId(orderId)).thenReturn(session); + when(mockSessionNotifier.getSessionByOrderId(orderId)) + .thenReturn(session); // Simulate that tradeIndex=3 has already been used mockServerTradeIndex.userTradeIndices[userPubKey] = 3; @@ -318,7 +325,8 @@ void main() { fullPrivacy: true, ); - when(mockSessionManager.getSessionByOrderId(orderId)).thenReturn(session); + when(mockSessionNotifier.getSessionByOrderId(orderId)) + .thenReturn(session); // Mock NostrService's createRumor, createSeal, createWrap, publishEvent when(mockNostrService.createRumor(any, any, any, any)) diff --git a/test/services/mostro_service_test.mocks.dart b/test/services/mostro_service_test.mocks.dart index f04e4f94..7749e35b 100644 --- a/test/services/mostro_service_test.mocks.dart +++ b/test/services/mostro_service_test.mocks.dart @@ -3,19 +3,23 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i6; +import 'dart:async' as _i9; import 'package:dart_nostr/dart_nostr.dart' as _i3; -import 'package:dart_nostr/nostr/model/relay_informations.dart' as _i7; -import 'package:flutter_riverpod/flutter_riverpod.dart' as _i11; +import 'package:dart_nostr/nostr/model/relay_informations.dart' as _i10; +import 'package:flutter_riverpod/flutter_riverpod.dart' as _i13; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i8; +import 'package:mockito/src/dummies.dart' as _i11; +import 'package:mostro_mobile/data/models/enums/role.dart' as _i14; +import 'package:mostro_mobile/data/models/mostro_message.dart' as _i7; +import 'package:mostro_mobile/data/models/payload.dart' as _i6; import 'package:mostro_mobile/data/models/session.dart' as _i4; -import 'package:mostro_mobile/data/repositories/session_manager.dart' as _i9; +import 'package:mostro_mobile/data/repositories/mostro_storage.dart' as _i16; import 'package:mostro_mobile/features/settings/settings.dart' as _i2; -import 'package:mostro_mobile/services/nostr_service.dart' as _i5; -import 'package:mostro_mobile/shared/notifiers/session_notifier.dart' as _i10; -import 'package:state_notifier/state_notifier.dart' as _i12; +import 'package:mostro_mobile/services/nostr_service.dart' as _i8; +import 'package:mostro_mobile/shared/notifiers/session_notifier.dart' as _i12; +import 'package:sembast/sembast.dart' as _i5; +import 'package:state_notifier/state_notifier.dart' as _i15; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -51,10 +55,28 @@ class _FakeSession_3 extends _i1.SmartFake implements _i4.Session { : super(parent, parentInvocation); } +class _FakeDatabase_4 extends _i1.SmartFake implements _i5.Database { + _FakeDatabase_4(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeStoreRef_5 + extends _i1.SmartFake + implements _i5.StoreRef { + _FakeStoreRef_5(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMostroMessage_6 extends _i1.SmartFake + implements _i7.MostroMessage { + _FakeMostroMessage_6(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + /// A class which mocks [NostrService]. /// /// See the documentation for Mockito's code generation for more information. -class MockNostrService extends _i1.Mock implements _i5.NostrService { +class MockNostrService extends _i1.Mock implements _i8.NostrService { MockNostrService() { _i1.throwOnMissingStub(this); } @@ -79,69 +101,79 @@ class MockNostrService extends _i1.Mock implements _i5.NostrService { as bool); @override - _i6.Future init() => + _i9.Future init() => (super.noSuchMethod( Invocation.method(#init, []), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override - _i6.Future updateSettings(_i2.Settings? newSettings) => + _i9.Future updateSettings(_i2.Settings? newSettings) => (super.noSuchMethod( Invocation.method(#updateSettings, [newSettings]), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override - _i6.Future<_i7.RelayInformations?> getRelayInfo(String? relayUrl) => + _i9.Future<_i10.RelayInformations?> getRelayInfo(String? relayUrl) => (super.noSuchMethod( Invocation.method(#getRelayInfo, [relayUrl]), - returnValue: _i6.Future<_i7.RelayInformations?>.value(), + returnValue: _i9.Future<_i10.RelayInformations?>.value(), ) - as _i6.Future<_i7.RelayInformations?>); + as _i9.Future<_i10.RelayInformations?>); @override - _i6.Future publishEvent(_i3.NostrEvent? event) => + _i9.Future publishEvent(_i3.NostrEvent? event) => (super.noSuchMethod( Invocation.method(#publishEvent, [event]), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override - _i6.Stream<_i3.NostrEvent> subscribeToEvents(_i3.NostrFilter? filter) => + _i9.Future> fecthEvents(_i3.NostrFilter? filter) => + (super.noSuchMethod( + Invocation.method(#fecthEvents, [filter]), + returnValue: _i9.Future>.value( + <_i3.NostrEvent>[], + ), + ) + as _i9.Future>); + + @override + _i9.Stream<_i3.NostrEvent> subscribeToEvents(_i3.NostrFilter? filter) => (super.noSuchMethod( Invocation.method(#subscribeToEvents, [filter]), - returnValue: _i6.Stream<_i3.NostrEvent>.empty(), + returnValue: _i9.Stream<_i3.NostrEvent>.empty(), ) - as _i6.Stream<_i3.NostrEvent>); + as _i9.Stream<_i3.NostrEvent>); @override - _i6.Future disconnectFromRelays() => + _i9.Future disconnectFromRelays() => (super.noSuchMethod( Invocation.method(#disconnectFromRelays, []), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override - _i6.Future<_i3.NostrKeyPairs> generateKeyPair() => + _i9.Future<_i3.NostrKeyPairs> generateKeyPair() => (super.noSuchMethod( Invocation.method(#generateKeyPair, []), - returnValue: _i6.Future<_i3.NostrKeyPairs>.value( + returnValue: _i9.Future<_i3.NostrKeyPairs>.value( _FakeNostrKeyPairs_1( this, Invocation.method(#generateKeyPair, []), ), ), ) - as _i6.Future<_i3.NostrKeyPairs>); + as _i9.Future<_i3.NostrKeyPairs>); @override _i3.NostrKeyPairs generateKeyPairFromPrivateKey(String? privateKey) => @@ -158,7 +190,7 @@ class MockNostrService extends _i1.Mock implements _i5.NostrService { String getMostroPubKey() => (super.noSuchMethod( Invocation.method(#getMostroPubKey, []), - returnValue: _i8.dummyValue( + returnValue: _i11.dummyValue( this, Invocation.method(#getMostroPubKey, []), ), @@ -166,7 +198,7 @@ class MockNostrService extends _i1.Mock implements _i5.NostrService { as String); @override - _i6.Future<_i3.NostrEvent> createNIP59Event( + _i9.Future<_i3.NostrEvent> createNIP59Event( String? content, String? recipientPubKey, String? senderPrivateKey, @@ -177,7 +209,7 @@ class MockNostrService extends _i1.Mock implements _i5.NostrService { recipientPubKey, senderPrivateKey, ]), - returnValue: _i6.Future<_i3.NostrEvent>.value( + returnValue: _i9.Future<_i3.NostrEvent>.value( _FakeNostrEvent_2( this, Invocation.method(#createNIP59Event, [ @@ -188,26 +220,26 @@ class MockNostrService extends _i1.Mock implements _i5.NostrService { ), ), ) - as _i6.Future<_i3.NostrEvent>); + as _i9.Future<_i3.NostrEvent>); @override - _i6.Future<_i3.NostrEvent> decryptNIP59Event( + _i9.Future<_i3.NostrEvent> decryptNIP59Event( _i3.NostrEvent? event, String? privateKey, ) => (super.noSuchMethod( Invocation.method(#decryptNIP59Event, [event, privateKey]), - returnValue: _i6.Future<_i3.NostrEvent>.value( + returnValue: _i9.Future<_i3.NostrEvent>.value( _FakeNostrEvent_2( this, Invocation.method(#decryptNIP59Event, [event, privateKey]), ), ), ) - as _i6.Future<_i3.NostrEvent>); + as _i9.Future<_i3.NostrEvent>); @override - _i6.Future createRumor( + _i9.Future createRumor( _i3.NostrKeyPairs? senderKeyPair, String? wrapperKey, String? recipientPubKey, @@ -220,8 +252,8 @@ class MockNostrService extends _i1.Mock implements _i5.NostrService { recipientPubKey, content, ]), - returnValue: _i6.Future.value( - _i8.dummyValue( + returnValue: _i9.Future.value( + _i11.dummyValue( this, Invocation.method(#createRumor, [ senderKeyPair, @@ -232,10 +264,10 @@ class MockNostrService extends _i1.Mock implements _i5.NostrService { ), ), ) - as _i6.Future); + as _i9.Future); @override - _i6.Future createSeal( + _i9.Future createSeal( _i3.NostrKeyPairs? senderKeyPair, String? wrapperKey, String? recipientPubKey, @@ -248,8 +280,8 @@ class MockNostrService extends _i1.Mock implements _i5.NostrService { recipientPubKey, encryptedContent, ]), - returnValue: _i6.Future.value( - _i8.dummyValue( + returnValue: _i9.Future.value( + _i11.dummyValue( this, Invocation.method(#createSeal, [ senderKeyPair, @@ -260,10 +292,10 @@ class MockNostrService extends _i1.Mock implements _i5.NostrService { ), ), ) - as _i6.Future); + as _i9.Future); @override - _i6.Future<_i3.NostrEvent> createWrap( + _i9.Future<_i3.NostrEvent> createWrap( _i3.NostrKeyPairs? wrapperKeyPair, String? sealedContent, String? recipientPubKey, @@ -274,7 +306,7 @@ class MockNostrService extends _i1.Mock implements _i5.NostrService { sealedContent, recipientPubKey, ]), - returnValue: _i6.Future<_i3.NostrEvent>.value( + returnValue: _i9.Future<_i3.NostrEvent>.value( _FakeNostrEvent_2( this, Invocation.method(#createWrap, [ @@ -285,50 +317,79 @@ class MockNostrService extends _i1.Mock implements _i5.NostrService { ), ), ) - as _i6.Future<_i3.NostrEvent>); + as _i9.Future<_i3.NostrEvent>); } -/// A class which mocks [SessionManager]. +/// A class which mocks [SessionNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockSessionManager extends _i1.Mock implements _i9.SessionManager { - MockSessionManager() { +class MockSessionNotifier extends _i1.Mock implements _i12.SessionNotifier { + MockSessionNotifier() { _i1.throwOnMissingStub(this); } @override - int get sessionExpirationHours => + List<_i4.Session> get sessions => (super.noSuchMethod( - Invocation.getter(#sessionExpirationHours), - returnValue: 0, + Invocation.getter(#sessions), + returnValue: <_i4.Session>[], ) - as int); + as List<_i4.Session>); @override - List<_i4.Session> get sessions => + set onError(_i13.ErrorListener? _onError) => super.noSuchMethod( + Invocation.setter(#onError, _onError), + returnValueForMissingStub: null, + ); + + @override + bool get mounted => + (super.noSuchMethod(Invocation.getter(#mounted), returnValue: false) + as bool); + + @override + _i9.Stream> get stream => (super.noSuchMethod( - Invocation.getter(#sessions), + Invocation.getter(#stream), + returnValue: _i9.Stream>.empty(), + ) + as _i9.Stream>); + + @override + List<_i4.Session> get state => + (super.noSuchMethod( + Invocation.getter(#state), returnValue: <_i4.Session>[], ) as List<_i4.Session>); @override - _i6.Future init() => + set state(List<_i4.Session>? value) => super.noSuchMethod( + Invocation.setter(#state, value), + returnValueForMissingStub: null, + ); + + @override + List<_i4.Session> get debugState => (super.noSuchMethod( - Invocation.method(#init, []), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + Invocation.getter(#debugState), + returnValue: <_i4.Session>[], ) - as _i6.Future); + as List<_i4.Session>); + + @override + bool get hasListeners => + (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) + as bool); @override - _i6.Future reset() => + _i9.Future init() => (super.noSuchMethod( - Invocation.method(#reset, []), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + Invocation.method(#init, []), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override void updateSettings(_i2.Settings? settings) => super.noSuchMethod( @@ -337,26 +398,32 @@ class MockSessionManager extends _i1.Mock implements _i9.SessionManager { ); @override - _i6.Future<_i4.Session> newSession({String? orderId}) => + _i9.Future<_i4.Session> newSession({String? orderId, _i14.Role? role}) => (super.noSuchMethod( - Invocation.method(#newSession, [], {#orderId: orderId}), - returnValue: _i6.Future<_i4.Session>.value( + Invocation.method(#newSession, [], { + #orderId: orderId, + #role: role, + }), + returnValue: _i9.Future<_i4.Session>.value( _FakeSession_3( this, - Invocation.method(#newSession, [], {#orderId: orderId}), + Invocation.method(#newSession, [], { + #orderId: orderId, + #role: role, + }), ), ), ) - as _i6.Future<_i4.Session>); + as _i9.Future<_i4.Session>); @override - _i6.Future saveSession(_i4.Session? session) => + _i9.Future saveSession(_i4.Session? session) => (super.noSuchMethod( Invocation.method(#saveSession, [session]), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override _i4.Session? getSessionByOrderId(String? orderId) => @@ -364,177 +431,290 @@ class MockSessionManager extends _i1.Mock implements _i9.SessionManager { as _i4.Session?); @override - _i6.Future<_i4.Session?> loadSession(int? keyIndex) => + _i9.Future<_i4.Session?> loadSession(int? keyIndex) => (super.noSuchMethod( Invocation.method(#loadSession, [keyIndex]), - returnValue: _i6.Future<_i4.Session?>.value(), + returnValue: _i9.Future<_i4.Session?>.value(), ) - as _i6.Future<_i4.Session?>); + as _i9.Future<_i4.Session?>); @override - _i6.Future deleteSession(int? sessionId) => + _i9.Future reset() => + (super.noSuchMethod( + Invocation.method(#reset, []), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future deleteSession(String? sessionId) => (super.noSuchMethod( Invocation.method(#deleteSession, [sessionId]), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override - _i6.Future clearExpiredSessions() => + _i9.Future clearExpiredSessions() => (super.noSuchMethod( Invocation.method(#clearExpiredSessions, []), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Future); + as _i9.Future); @override void dispose() => super.noSuchMethod( Invocation.method(#dispose, []), returnValueForMissingStub: null, ); + + @override + bool updateShouldNotify(List<_i4.Session>? old, List<_i4.Session>? current) => + (super.noSuchMethod( + Invocation.method(#updateShouldNotify, [old, current]), + returnValue: false, + ) + as bool); + + @override + _i13.RemoveListener addListener( + _i15.Listener>? listener, { + bool? fireImmediately = true, + }) => + (super.noSuchMethod( + Invocation.method( + #addListener, + [listener], + {#fireImmediately: fireImmediately}, + ), + returnValue: () {}, + ) + as _i13.RemoveListener); } -/// A class which mocks [SessionNotifier]. +/// A class which mocks [MostroStorage]. /// /// See the documentation for Mockito's code generation for more information. -class MockSessionNotifier extends _i1.Mock implements _i10.SessionNotifier { - MockSessionNotifier() { +class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { + MockMostroStorage() { _i1.throwOnMissingStub(this); } @override - set onError(_i11.ErrorListener? _onError) => super.noSuchMethod( - Invocation.setter(#onError, _onError), - returnValueForMissingStub: null, - ); + _i5.Database get db => + (super.noSuchMethod( + Invocation.getter(#db), + returnValue: _FakeDatabase_4(this, Invocation.getter(#db)), + ) + as _i5.Database); @override - bool get mounted => - (super.noSuchMethod(Invocation.getter(#mounted), returnValue: false) - as bool); + _i5.StoreRef> get store => + (super.noSuchMethod( + Invocation.getter(#store), + returnValue: _FakeStoreRef_5>( + this, + Invocation.getter(#store), + ), + ) + as _i5.StoreRef>); @override - _i6.Stream> get stream => + _i9.Future addMessage(_i7.MostroMessage<_i6.Payload>? message) => (super.noSuchMethod( - Invocation.getter(#stream), - returnValue: _i6.Stream>.empty(), + Invocation.method(#addMessage, [message]), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as _i6.Stream>); + as _i9.Future); @override - List<_i4.Session> get state => + _i9.Future addMessages( + List<_i7.MostroMessage<_i6.Payload>>? messages, + ) => (super.noSuchMethod( - Invocation.getter(#state), - returnValue: <_i4.Session>[], + Invocation.method(#addMessages, [messages]), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), ) - as List<_i4.Session>); + as _i9.Future); @override - set state(List<_i4.Session>? value) => super.noSuchMethod( - Invocation.setter(#state, value), - returnValueForMissingStub: null, - ); + _i9.Future<_i7.MostroMessage<_i6.Payload>?> + getMessageById(String? orderId) => + (super.noSuchMethod( + Invocation.method(#getMessageById, [orderId]), + returnValue: _i9.Future<_i7.MostroMessage<_i6.Payload>?>.value(), + ) + as _i9.Future<_i7.MostroMessage<_i6.Payload>?>); @override - List<_i4.Session> get debugState => + _i9.Future>> getAllMessages() => (super.noSuchMethod( - Invocation.getter(#debugState), - returnValue: <_i4.Session>[], + Invocation.method(#getAllMessages, []), + returnValue: _i9.Future>>.value( + <_i7.MostroMessage<_i6.Payload>>[], + ), ) - as List<_i4.Session>); + as _i9.Future>>); @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) - as bool); + _i9.Future deleteMessage(String? orderId) => + (super.noSuchMethod( + Invocation.method(#deleteMessage, [orderId]), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); @override - _i6.Future<_i4.Session> newSession({String? orderId}) => + _i9.Future deleteAllMessages() => (super.noSuchMethod( - Invocation.method(#newSession, [], {#orderId: orderId}), - returnValue: _i6.Future<_i4.Session>.value( - _FakeSession_3( - this, - Invocation.method(#newSession, [], {#orderId: orderId}), - ), + Invocation.method(#deleteAllMessages, []), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future deleteAllMessagesById(String? orderId) => + (super.noSuchMethod( + Invocation.method(#deleteAllMessagesById, [orderId]), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future>> + getMessagesOfType() => + (super.noSuchMethod( + Invocation.method(#getMessagesOfType, []), + returnValue: _i9.Future>>.value( + <_i7.MostroMessage>[], ), ) - as _i6.Future<_i4.Session>); + as _i9.Future>>); @override - _i6.Future reset() => + _i9.Future>> getMessagesForId( + String? orderId, + ) => (super.noSuchMethod( - Invocation.method(#reset, []), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + Invocation.method(#getMessagesForId, [orderId]), + returnValue: _i9.Future>>.value( + <_i7.MostroMessage<_i6.Payload>>[], + ), ) - as _i6.Future); + as _i9.Future>>); @override - void refresh() => super.noSuchMethod( - Invocation.method(#refresh, []), - returnValueForMissingStub: null, - ); + _i7.MostroMessage<_i6.Payload> fromDbMap( + String? key, + Map? jsonMap, + ) => + (super.noSuchMethod( + Invocation.method(#fromDbMap, [key, jsonMap]), + returnValue: _FakeMostroMessage_6<_i6.Payload>( + this, + Invocation.method(#fromDbMap, [key, jsonMap]), + ), + ) + as _i7.MostroMessage<_i6.Payload>); @override - _i6.Future saveSession(_i4.Session? session) => + Map toDbMap(_i7.MostroMessage<_i6.Payload>? item) => (super.noSuchMethod( - Invocation.method(#saveSession, [session]), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + Invocation.method(#toDbMap, [item]), + returnValue: {}, ) - as _i6.Future); + as Map); @override - _i6.Future deleteSession(int? sessionId) => + String messageKey(_i7.MostroMessage<_i6.Payload>? msg) => (super.noSuchMethod( - Invocation.method(#deleteSession, [sessionId]), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), + Invocation.method(#messageKey, [msg]), + returnValue: _i11.dummyValue( + this, + Invocation.method(#messageKey, [msg]), + ), ) - as _i6.Future); + as String); @override - _i4.Session? getSessionByOrderId(String? orderId) => - (super.noSuchMethod(Invocation.method(#getSessionByOrderId, [orderId])) - as _i4.Session?); + _i9.Future putItem(String? id, _i7.MostroMessage<_i6.Payload>? item) => + (super.noSuchMethod( + Invocation.method(#putItem, [id, item]), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); @override - _i6.Future<_i4.Session?> loadSession(int? keyIndex) => + _i9.Future<_i7.MostroMessage<_i6.Payload>?> getItem(String? id) => (super.noSuchMethod( - Invocation.method(#loadSession, [keyIndex]), - returnValue: _i6.Future<_i4.Session?>.value(), + Invocation.method(#getItem, [id]), + returnValue: _i9.Future<_i7.MostroMessage<_i6.Payload>?>.value(), ) - as _i6.Future<_i4.Session?>); + as _i9.Future<_i7.MostroMessage<_i6.Payload>?>); @override - void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); + _i9.Future hasItem(String? id) => + (super.noSuchMethod( + Invocation.method(#hasItem, [id]), + returnValue: _i9.Future.value(false), + ) + as _i9.Future); @override - bool updateShouldNotify(List<_i4.Session>? old, List<_i4.Session>? current) => + _i9.Future>> getAllItems() => (super.noSuchMethod( - Invocation.method(#updateShouldNotify, [old, current]), - returnValue: false, + Invocation.method(#getAllItems, []), + returnValue: _i9.Future>>.value( + <_i7.MostroMessage<_i6.Payload>>[], + ), ) - as bool); + as _i9.Future>>); @override - _i11.RemoveListener addListener( - _i12.Listener>? listener, { - bool? fireImmediately = true, + _i9.Future deleteItem(String? id) => + (super.noSuchMethod( + Invocation.method(#deleteItem, [id]), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future deleteAllItems() => + (super.noSuchMethod( + Invocation.method(#deleteAllItems, []), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future> deleteWhere( + bool Function(_i7.MostroMessage<_i6.Payload>)? filter, { + int? maxBatchSize, }) => (super.noSuchMethod( Invocation.method( - #addListener, - [listener], - {#fireImmediately: fireImmediately}, + #deleteWhere, + [filter], + {#maxBatchSize: maxBatchSize}, ), - returnValue: () {}, + returnValue: _i9.Future>.value([]), ) - as _i11.RemoveListener); + as _i9.Future>); + + @override + void dispose() => super.noSuchMethod( + Invocation.method(#dispose, []), + returnValueForMissingStub: null, + ); } From 917de9f9fd21f6191db45e41b57cc03fbd97d9a5 Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 5 Apr 2025 00:27:02 +1000 Subject: [PATCH 089/149] Updated Settings and Account Screens --- lib/data/models/cant_do.dart | 14 +- lib/data/models/dispute.dart | 6 +- lib/data/models/mostro_message.dart | 3 + lib/data/models/payload.dart | 4 +- .../key_manager/key_management_screen.dart | 195 ++++++++++++------ .../notfiers/abstract_mostro_notifier.dart | 21 +- .../order/notfiers/dispute_notifier.dart | 18 ++ .../providers/order_notifier_provider.dart | 8 + lib/features/settings/settings_screen.dart | 191 +++++++++++------ .../widgets/mostro_message_detail_widget.dart | 14 +- lib/services/mostro_service.dart | 16 +- 11 files changed, 333 insertions(+), 157 deletions(-) create mode 100644 lib/features/order/notfiers/dispute_notifier.dart diff --git a/lib/data/models/cant_do.dart b/lib/data/models/cant_do.dart index 13d3974c..051f7f68 100644 --- a/lib/data/models/cant_do.dart +++ b/lib/data/models/cant_do.dart @@ -5,7 +5,19 @@ class CantDo implements Payload { final CantDoReason cantDoReason; factory CantDo.fromJson(Map json) { - return CantDo(cantDoReason: CantDoReason.fromString(json['cant-do'])); + if (json['cant_do'] is String) { + return CantDo( + cantDoReason: CantDoReason.fromString( + json['cant_do'], + ), + ); + } else { + return CantDo( + cantDoReason: CantDoReason.fromString( + json['cant_do']['cant-do'], + ), + ); + } } CantDo({required this.cantDoReason}); diff --git a/lib/data/models/dispute.dart b/lib/data/models/dispute.dart index 4f37b094..c25e7b71 100644 --- a/lib/data/models/dispute.dart +++ b/lib/data/models/dispute.dart @@ -12,10 +12,10 @@ class Dispute implements Payload { }; } - factory Dispute.fromJson(List json) { - final oid = json[0]; + factory Dispute.fromJson(Map json) { + final oid = json['dispute']; return Dispute( - disputeId: oid, + disputeId: oid is List ? oid[0] : oid, ); } diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 9a084a7b..94e88202 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -38,6 +38,9 @@ class MostroMessage { } factory MostroMessage.fromJson(Map json) { + + json = json['order'] ?? json['cant-do'] ?? json; + return MostroMessage( action: Action.fromString(json['action']), requestId: json['request_id'], diff --git a/lib/data/models/payload.dart b/lib/data/models/payload.dart index 7b3c98df..b7c1f285 100644 --- a/lib/data/models/payload.dart +++ b/lib/data/models/payload.dart @@ -15,11 +15,11 @@ abstract class Payload { } else if (json.containsKey('payment_request')) { return PaymentRequest.fromJson(json['payment_request']); } else if (json.containsKey('cant_do')) { - return CantDo.fromJson(json['cant_do']); + return CantDo.fromJson(json); } else if (json.containsKey('peer')) { return Peer.fromJson(json['peer']); } else if (json.containsKey('dispute')) { - return Dispute.fromJson(json['dispute']); + return Dispute.fromJson(json); } else if (json.containsKey('rating_user')) { return RatingUser.fromJson(json['rating_user']); } else { diff --git a/lib/features/key_manager/key_management_screen.dart b/lib/features/key_manager/key_management_screen.dart index b31ba8de..de9c3c8d 100644 --- a/lib/features/key_manager/key_management_screen.dart +++ b/lib/features/key_manager/key_management_screen.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; +import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; +import 'package:mostro_mobile/shared/widgets/custom_card.dart'; +import 'package:mostro_mobile/shared/widgets/privacy_switch_widget.dart'; class KeyManagementScreen extends ConsumerStatefulWidget { const KeyManagementScreen({super.key}); @@ -16,7 +18,6 @@ class KeyManagementScreen extends ConsumerStatefulWidget { } class _KeyManagementScreenState extends ConsumerState { - String? _masterKey; String? _mnemonic; int? _tradeKeyIndex; bool _loading = false; @@ -36,17 +37,13 @@ class _KeyManagementScreenState extends ConsumerState { final keyManager = ref.read(keyManagerProvider); final hasMaster = await keyManager.hasMasterKey(); if (hasMaster) { - final masterKeyPairs = keyManager.masterKeyPair; - _masterKey = masterKeyPairs?.private; _mnemonic = await keyManager.getMnemonic(); _tradeKeyIndex = await keyManager.getCurrentKeyIndex(); } else { - _masterKey = 'No master key found'; _mnemonic = 'No mnemonic found'; _tradeKeyIndex = 0; } } catch (e) { - _masterKey = 'Error: $e'; _mnemonic = 'Error: $e'; } finally { setState(() { @@ -55,13 +52,6 @@ class _KeyManagementScreenState extends ConsumerState { } } - void _copyToClipboard(String text, String label) { - Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$label copied to clipboard')), - ); - } - Future _generateNewMasterKey() async { final sessionNotifer = ref.read(sessionNotifierProvider.notifier); await sessionNotifer.reset(); @@ -90,17 +80,22 @@ class _KeyManagementScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final settings = ref.watch(settingsProvider); + final textTheme = AppTheme.theme.textTheme; return Scaffold( appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( - icon: const HeroIcon(HeroIcons.arrowLeft, color: AppTheme.cream1), + icon: const HeroIcon( + HeroIcons.arrowLeft, + color: AppTheme.cream1, + ), onPressed: () => context.pop(), ), title: Text( - 'KEY MANAGEMENT', + 'Account', style: TextStyle( color: AppTheme.cream1, ), @@ -112,70 +107,136 @@ class _KeyManagementScreenState extends ConsumerState { : SingleChildScrollView( padding: AppTheme.largePadding, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + spacing: 24, children: [ - // Master Key - Text('Master Key', style: textTheme.titleLarge), - const SizedBox(height: 8), - SelectableText( - _masterKey ?? '', - ), - const SizedBox(height: 8), - TextButton( - onPressed: _masterKey != null - ? () => _copyToClipboard(_masterKey!, 'Master Key') - : null, - child: const Text('Copy Master Key'), - ), - const SizedBox(height: 8), - const Divider(color: AppTheme.grey2), - const SizedBox(height: 16), - // Mnemonic - Text('Mnemonic', style: textTheme.titleLarge), - const SizedBox(height: 8), - SelectableText( - _mnemonic ?? '', + // Secret Words + CustomCard( + color: AppTheme.dark2, + padding: const EdgeInsets.all(16), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8, + children: [ + const Icon( + Icons.key, + color: AppTheme.mostroGreen, + ), + Text('Secret Words', style: textTheme.titleLarge), + ], + ), + Text('To restore your account', + style: textTheme.bodyMedium + ?.copyWith(color: AppTheme.grey2)), + SelectableText( + _mnemonic ?? '', + ), + ], + ), ), - const SizedBox(height: 8), - TextButton( - onPressed: _mnemonic != null - ? () => _copyToClipboard(_mnemonic!, 'Mnemonic') - : null, - child: const Text('Copy Mnemonic'), + // Privacy + CustomCard( + color: AppTheme.dark2, + padding: const EdgeInsets.all(16), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8, + children: [ + const Icon( + Icons.key, + color: AppTheme.mostroGreen, + ), + Text('Privacy', style: textTheme.titleLarge), + ], + ), + Text('Control your privacy settings', + style: textTheme.bodyMedium + ?.copyWith(color: AppTheme.grey2)), + PrivacySwitch( + initialValue: settings.fullPrivacyMode, + onChanged: (bool value) { + ref + .watch(settingsProvider.notifier) + .updatePrivacyMode(value); + }), + ], + ), ), - const SizedBox(height: 8), - const Divider(color: AppTheme.grey2), - const SizedBox(height: 16), // Trade Key Index - Text( - 'Current Trade Key Index: ${_tradeKeyIndex ?? 'N/A'}', + CustomCard( + color: AppTheme.dark2, + padding: const EdgeInsets.all(16), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8, + children: [ + const Icon( + Icons.sync, + color: AppTheme.mostroGreen, + ), + Text('Current Trade Index', + style: textTheme.titleLarge), + ], + ), + Text('Your trade counter', + style: textTheme.bodyMedium + ?.copyWith(color: AppTheme.grey2)), + CustomCard( + color: AppTheme.dark1, + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Text( + '${_tradeKeyIndex ?? 'N/A'}', + ), + const SizedBox(width: 24), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text('Increments with each trade', + style: textTheme.bodyMedium + ?.copyWith(color: AppTheme.grey2)), + ], + ), + ], + ), + ), + ], + ), ), - const SizedBox(height: 16), - // Buttons to generate and delete keys - Row( + Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( onPressed: _generateNewMasterKey, - child: const Text('Generate New Master Key'), + child: Row( + spacing: 8, + children: [ + const Icon(Icons.person_2_outlined), + const Text('Generate New User'), + ], + ), ), ], ), - const SizedBox(height: 16), - // Import Key - Text('Import Key from Mnemonic', style: textTheme.titleLarge), - const SizedBox(height: 8), - TextField( - controller: _importController, - decoration: const InputDecoration( - labelText: 'Enter key or mnemonic', - labelStyle: TextStyle(color: AppTheme.grey2), - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - ElevatedButton( + OutlinedButton( onPressed: _importKey, - child: const Text('Import Key'), + child: Row( + spacing: 8, + children: [ + const Icon(Icons.download), + const Text('Import Mostro User'), + ], + ), ), ], ), diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index 7f706301..b6be9bea 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -17,7 +17,8 @@ import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/providers/session_providers.dart'; -class AbstractMostroNotifier extends StateNotifier { +class AbstractMostroNotifier + extends StateNotifier { final String orderId; final Ref ref; @@ -34,12 +35,11 @@ class AbstractMostroNotifier extends StateNotifier(orderId) ?? state; } - void subscribe() { subscription = ref.listen(sessionMessagesProvider(orderId), (_, next) { next.when( data: (msg) { - handleEvent(msg); + handleEvent(msg); }, error: (error, stack) => handleError(error, stack), loading: () {}, @@ -154,7 +154,6 @@ class AbstractMostroNotifier extends StateNotifier extends StateNotifier()!; + notifProvider.showInformation(state.action, values: { + 'id': state.id!, + 'user_token': dispute.disputeId, + }); + case Action.cooperativeCancelAccepted: + notifProvider.showInformation(state.action, values: { + 'id': state.id!, + }); + case Action.cooperativeCancelInitiatedByPeer: + notifProvider.showInformation(state.action, values: { + 'id': state.id!, + }); default: notifProvider.showInformation(state.action, values: {}); break; diff --git a/lib/features/order/notfiers/dispute_notifier.dart b/lib/features/order/notfiers/dispute_notifier.dart new file mode 100644 index 00000000..fa3618f0 --- /dev/null +++ b/lib/features/order/notfiers/dispute_notifier.dart @@ -0,0 +1,18 @@ +import 'package:mostro_mobile/data/models/dispute.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; + +class DisputeNotifier extends AbstractMostroNotifier { + DisputeNotifier(super.orderId, super.ref) { + sync(); + subscribe(); + } + + @override + void handleEvent(MostroMessage event) { + if (event.payload is Dispute) { + state = event; + handleOrderUpdate(); + } + } +} diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index 1a5dfd40..91dc5282 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/features/order/notfiers/add_order_notifier.dart'; +import 'package:mostro_mobile/features/order/notfiers/dispute_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/payment_request_notifier.dart'; import 'package:mostro_mobile/services/event_bus.dart'; @@ -14,6 +15,7 @@ final orderNotifierProvider = (ref, orderId) { ref.read(cantDoNotifierProvider(orderId)); ref.read(paymentNotifierProvider(orderId)); + ref.read(disputeNotifierProvider(orderId)); return OrderNotifier( orderId, ref, @@ -45,6 +47,12 @@ final paymentNotifierProvider = }, ); +final disputeNotifierProvider = + StateNotifierProvider.family( + (ref, orderId) { + return DisputeNotifier(orderId, ref); + }, +); // This provider tracks the currently selected OrderType tab @riverpod diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index 0805ed0e..a22389bd 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -6,7 +6,7 @@ import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/relays/widgets/relay_selector.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/widgets/currency_combo_box.dart'; -import 'package:mostro_mobile/shared/widgets/privacy_switch_widget.dart'; +import 'package:mostro_mobile/shared/widgets/custom_card.dart'; class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @@ -27,7 +27,7 @@ class SettingsScreen extends ConsumerWidget { onPressed: () => context.pop(), ), title: const Text( - 'SETTINGS', + 'Settings', style: TextStyle( color: AppTheme.cream1, ), @@ -37,78 +37,133 @@ class SettingsScreen extends ConsumerWidget { body: Column( children: [ Expanded( - child: Container( - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.dark2, - borderRadius: BorderRadius.circular(20), - ), - child: SingleChildScrollView( - padding: AppTheme.largePadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // General Settings - Text('General Settings', style: textTheme.titleLarge), - const SizedBox(height: 8), - PrivacySwitch( - initialValue: settings.fullPrivacyMode, - onChanged: (bool value) { - ref - .watch(settingsProvider.notifier) - .updatePrivacyMode(value); - }), - const SizedBox(height: 8), - CurrencyComboBox( - label: "Default Fiat Currency", - onSelected: (fiatCode) { - ref - .watch(settingsProvider.notifier) - .updateDefaultFiatCode(fiatCode); - }, - ), - const SizedBox(height: 8), - const Divider(color: AppTheme.grey2), - const SizedBox(height: 16), - // Relays - Text('Relays', style: textTheme.titleLarge), - const SizedBox(height: 8), - RelaySelector(), - Row( - mainAxisAlignment: MainAxisAlignment.end, + child: SingleChildScrollView( + padding: AppTheme.largePadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 24, + children: [ + // General Settings + CustomCard( + color: AppTheme.dark2, + padding: const EdgeInsets.all(16), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ElevatedButton( - onPressed: () { - RelaySelector.showAddDialog(context, ref); + Row( + spacing: 8, + children: [ + const Icon( + Icons.toll, + color: AppTheme.mostroGreen, + ), + Text('Currency', style: textTheme.titleLarge), + ], + ), + Text('Set your default fiat currency', + style: textTheme.bodyMedium + ?.copyWith(color: AppTheme.grey2)), + CurrencyComboBox( + label: "Default Fiat Currency", + onSelected: (fiatCode) { + ref + .watch(settingsProvider.notifier) + .updateDefaultFiatCode(fiatCode); }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, + ), + ], + ), + ), + CustomCard( + color: AppTheme.dark2, + padding: const EdgeInsets.all(16), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8, + children: [ + const Icon( + Icons.sensors, + color: AppTheme.mostroGreen, + ), + Text('Relays', style: textTheme.titleLarge), + ], + ), + Text('Select the Nostr relays you connect to', + style: textTheme.bodyMedium + ?.copyWith(color: AppTheme.grey2)), + RelaySelector(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton( + onPressed: () { + RelaySelector.showAddDialog(context, ref); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.mostroGreen, + ), + child: const Text('Add Relay'), + ), + ], + ), + ], + ), + ), + CustomCard( + color: AppTheme.dark2, + padding: const EdgeInsets.all(16), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8, + children: [ + const Icon( + Icons.flash_on, + color: AppTheme.mostroGreen, + ), + Text('Mostro', style: textTheme.titleLarge), + ], + ), + Text( + 'Enter the public key of the Mostro you will use', + style: textTheme.bodyMedium + ?.copyWith(color: AppTheme.grey2)), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.dark1, + borderRadius: BorderRadius.circular(8), + ), + child: TextFormField( + key: key, + controller: mostroTextContoller, + style: const TextStyle(color: AppTheme.cream1), + onChanged: (value) => ref + .watch(settingsProvider.notifier) + .updateMostroInstance(value), + decoration: InputDecoration( + border: InputBorder.none, + labelText: 'Mostro Pubkey', + labelStyle: + const TextStyle(color: AppTheme.grey2), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), ), - child: const Text('Add Relay'), ), ], ), - const SizedBox(height: 8), - // Mostro - const Divider(color: AppTheme.grey2), - const SizedBox(height: 16), - Text('Mostro', style: textTheme.titleLarge), - const SizedBox(height: 8), - TextFormField( - key: key, - controller: mostroTextContoller, - style: const TextStyle(color: AppTheme.cream1), - onChanged: (value) => ref - .watch(settingsProvider.notifier) - .updateMostroInstance(value), - decoration: InputDecoration( - border: InputBorder.none, - labelText: 'Mostro Pubkey', - labelStyle: const TextStyle(color: AppTheme.grey2), - ), - ) - ]), - ), + ), + ]), ), ), ], diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index 7b7270d6..d2fdefab 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -8,7 +8,6 @@ import 'package:mostro_mobile/data/models/enums/cant_do_reason.dart'; import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/data/models/peer.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; @@ -97,10 +96,9 @@ class MostroMessageDetail extends ConsumerWidget { ); break; case actions.Action.fiatSentOk: - final payload = mostroMessage.getPayload(); actionText = session!.role == Role.buyer - ? S.of(context)!.fiatSentOkBuyer(payload!.sellerTradePubkey!) - : S.of(context)!.fiatSentOkSeller(payload!.buyerTradePubkey!); + ? S.of(context)!.fiatSentOkBuyer(session.peer!.publicKey) + : S.of(context)!.fiatSentOkSeller(session.peer!.publicKey); break; case actions.Action.released: actionText = S.of(context)!.released('{seller_npub}'); @@ -129,13 +127,14 @@ class MostroMessageDetail extends ConsumerWidget { actionText = S.of(context)!.cooperativeCancelAccepted(order.orderId!); break; case actions.Action.disputeInitiatedByYou: - final payload = mostroMessage.getPayload(); + final payload = ref.read(disputeNotifierProvider(order.orderId!)).getPayload(); actionText = S .of(context)! .disputeInitiatedByYou(order.orderId!, payload!.disputeId); break; case actions.Action.disputeInitiatedByPeer: - final payload = mostroMessage.getPayload(); + + final payload = ref.read(disputeNotifierProvider(order.orderId!)).getPayload(); actionText = S .of(context)! .disputeInitiatedByPeer(order.orderId!, payload!.disputeId); @@ -162,7 +161,8 @@ class MostroMessageDetail extends ConsumerWidget { actionText = S.of(context)!.holdInvoicePaymentCanceled; break; case actions.Action.cantDo: - final cantDo = mostroMessage.getPayload(); + final msg = ref.read(cantDoNotifierProvider(order.orderId!)); + final cantDo = msg.getPayload(); switch (cantDo!.cantDoReason) { case CantDoReason.invalidSignature: actionText = S.of(context)!.invalidSignature; diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index bf08372e..549e37fd 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -67,11 +67,17 @@ class MostroService { final msgMap = result[0]; - final msg = MostroMessage.fromJson( - msgMap['order'] ?? msgMap['cant-do'], - ); - - ref.read(orderActionNotifierProvider(msg.id!).notifier).set(msg.action,); + final msg = MostroMessage.fromJson(msgMap); + + if (msg.id != null) { + ref + .read( + orderActionNotifierProvider(msg.id!).notifier, + ) + .set( + msg.action, + ); + } if (msg.action == actions.Action.canceled) { await _messageStorage.deleteAllMessagesById(session.orderId!); From 082250ecd8f7217be0813adc140e2b82608a51c5 Mon Sep 17 00:00:00 2001 From: devbtcp Date: Sun, 13 Apr 2025 13:21:32 +0200 Subject: [PATCH 090/149] add IT locale add IT locale translations arb file --- lib/l10n/intl_it.arb | 59 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 lib/l10n/intl_it.arb diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb new file mode 100644 index 00000000..f64a2049 --- /dev/null +++ b/lib/l10n/intl_it.arb @@ -0,0 +1,59 @@ +{ + "@@locale": "it", + "newOrder": "La tua offerta è stata pubblicata! Attendi fino a quando un altro utente accetta il tuo ordine. Sarà disponibile per {expiration_hours} ore. Puoi annullare questo ordine prima che un altro utente lo prenda premendo: Cancella.", + "canceled": "Hai annullato l'ordine ID: {id}!", + "payInvoice": "Paga questa Hodl Invoice di {amount} Sats per {fiat_amount} {fiat_code} per iniziare l'operazione. Se non la paghi entro {expiration_seconds} lo scambio sarà annullato.", + "addInvoice": "Inserisci una Invoice bolt11 di {amount} satoshi equivalente a {fiat_amount} {fiat_code}. Questa invoice riceverà i fondi una volta completato lo scambio. Se non fornisci una invoice entro {expiration_seconds} questo scambio sarà annullato.", + "waitingSellerToPay": "Attendi un momento per favore. Ho inviato una richiesta di pagamento al venditore per rilasciare i Sats per l'ordine ID {id}. Una volta effettuato il pagamento, vi connetterò entrambi. Se il venditore non completa il pagamento entro {expiration_seconds} minuti lo scambio sarà annullato.", + "waitingBuyerInvoice": "Pagamento ricevuto! I tuoi Sats sono ora 'custoditi' nel tuo portafoglio. Attendi un momento per favore. Ho richiesto all'acquirente di fornire una invoice lightning. Una volta ricevuta, vi connetterò entrambi, altrimenti se non la riceviamo entro {expiration_seconds} i tuoi Sats saranno nuovamente disponibili nel tuo portafoglio e lo scambio sarà annullato.", + "buyerInvoiceAccepted": "Fattura salvata con successo!", + "holdInvoicePaymentAccepted": "Contatta il venditore tramite la sua npub {seller_npub} ed ottieni i dettagli per inviare il pagamento di {fiat_amount} {fiat_code} utilizzando {payment_method}. Una volta inviato il pagamento, clicca: Pagamento inviato", + "buyerTookOrder": "Contatta l'acquirente, ecco il suo npub {buyer_npub}, per informarlo su come inviarti {fiat_amount} {fiat_code} tramite {payment_method}. Riceverai una notifica una volta che l'acquirente indicherà che il pagamento fiat è stato inviato. Successivamente, dovresti verificare se sono arrivati i fondi. Se l'acquirente non risponde, puoi iniziare l'annullamento dell'ordine o una disputa. Ricorda, un amministratore NON ti contatterà per risolvere il tuo ordine a meno che tu non apra prima una disputa.", + "fiatSentOkBuyer": "Ho informato {seller_npub} che hai inviato il pagamento. Quando il venditore confermerà di aver ricevuto il tuo pagamento, dovrebbe rilasciare i fondi. Se rifiuta, puoi aprire una disputa.", + "fiatSentOkSeller": "{buyer_npub} ha confermato di aver inviato il pagamento. Una volta confermato la ricezione dei fondi fiat, puoi rilasciare i Sats. Dopo il rilascio, i Sats andranno all'acquirente, l'azione è irreversibile, quindi procedi solo se sei sicuro. Se vuoi rilasciare i Sats all'acquirente, premi: Rilascia Fondi.", + "released": "{seller_npub} ha già rilasciato i Sats! Aspetta solo che la tua invoice venga pagata. Ricorda, il tuo portafoglio deve essere online per ricevere i fondi tramite Lightning Network.", + "purchaseCompleted": "L'acquisto di nuovi satoshi è stato completato con successo. Goditi questi dolci Sats!", + "holdInvoicePaymentSettled": "Il tuo scambio di vendita di Sats è stato completato dopo aver confermato il pagamento da {buyer_npub}.", + "rate": "Dai una valutazione alla controparte", + "rateReceived": "Valutazione salvata con successo!", + "cooperativeCancelInitiatedByYou": "Hai iniziato l'annullamento dell'ordine ID: {id}. La tua controparte deve anche concordare l'annullamento. Se non risponde, puoi aprire una disputa. Nota che nessun amministratore ti contatterà MAI riguardo questo annullamento a meno che tu non apra prima una disputa.", + "cooperativeCancelInitiatedByPeer": "La tua controparte vuole annullare l'ordine ID: {id}. Nota che nessun amministratore ti contatterà MAI riguardo questo annullamento a meno che tu non apra prima una disputa. Se concordi su tale annullamento, premi: Annulla Ordine.", + "cooperativeCancelAccepted": "L'ordine {id} è stato annullato con successo!", + "disputeInitiatedByYou": "Hai iniziato una disputa per l'ordine ID: {id}. Un amministratore sarà assegnato presto alla tua disputa. Una volta assegnato, riceverai il suo npub e solo questo account potrà assisterti. Devi contattare l'amministratore direttamente, ma se qualcuno ti contatta prima, assicurati di chiedergli di fornirti il token per la tua disputa. Il token di questa disputa è: {user_token}.", + "disputeInitiatedByPeer": "La tua controparte ha iniziato una disputa per l'ordine ID: {id}. Un amministratore sarà assegnato presto alla tua disputa. Una volta assegnato, ti condividerò il loro npub e solo loro potranno assisterti. Devi contattare l'amministratore direttamente, ma se qualcuno ti contatta prima, assicurati di chiedergli di fornirti il token per la tua disputa. Il token di questa disputa è: {user_token}.", + "adminTookDisputeAdmin": "Ecco i dettagli dell'ordine della disputa che hai preso: {details}. Devi determinare quale utente ha ragione e decidere se annullare o completare l'ordine. Nota che la tua decisione sarà finale e non può essere annullata.", + "adminTookDisputeUsers": "L'amministratore {admin_npub} gestirà la tua disputa. Devi contattare l'amministratore direttamente, ma se qualcuno ti contatta prima, assicurati di chiedergli di fornirti il token per la tua disputa..", + "adminCanceledAdmin": "Hai annullato l'ordine ID: {id}!", + "adminCanceledUsers": "L'amministratore ha annullato l'ordine ID: {id}!", + "adminSettledAdmin": "Hai completato l'ordine ID: {id}!", + "adminSettledUsers": "L'amministratore ha completato l'ordine ID: {id}!", + "paymentFailed": "Ho provato a inviarti i Sats ma il pagamento della tua invoice è fallito. Tenterò {payment_attempts} volte ancora ogni {payment_retries_interval} minuti. Per favore assicurati che il tuo nodo/portafoglio lightning sia online.", + "invoiceUpdated": "Invoice aggiornata con successo!", + "holdInvoicePaymentCanceled": "Invoice annullata; i tuoi Sats saranno nuovamente disponibili nel tuo portafoglio.", + "cantDo": "Non sei autorizzato a {action} per questo ordine!", + "adminAddSolver": "Hai aggiunto con successo l'amministratore {npub}.", + "invalidSignature": "L'azione non può essere completata perché la firma non è valida.", + "invalidTradeIndex": "L'indice di scambio fornito non è valido. Assicurati che il tuo client sia sincronizzato e riprova.", + "invalidAmount": "L'importo fornito non è valido. Verificalo e riprova.", + "invalidInvoice": "La fattura Lightning fornita non è valida. Controlla i dettagli della fattura e riprova.", + "invalidPaymentRequest": "La richiesta di pagamento non è valida o non può essere elaborata.", + "invalidPeer": "Non sei autorizzato ad eseguire questa azione.", + "invalidRating": "Il valore della valutazione è non valido o fuori dal range consentito.", + "invalidTextMessage": "Il messaggio di testo non è valido o contiene contenuti proibiti.", + "invalidOrderKind": "Il tipo di ordine non è valido.", + "invalidOrderStatus": "L'azione non può essere completata a causa dello stato attuale dell'ordine.", + "invalidPubkey": "L'azione non può essere completata perché la chiave pubblica non è valida.", + "invalidParameters": "L'azione non può essere completata a causa di parametri non validi. Rivedi i valori forniti e riprova.", + "orderAlreadyCanceled": "L'azione non può essere completata perché l'ordine è già stato annullato.", + "cantCreateUser": "L'azione non può essere completata perché l'utente non può essere creato.", + "isNotYourOrder": "Questo ordine non appartiene a te.", + "notAllowedByStatus": "Non sei autorizzato ad eseguire questa azione perché lo stato dell'ordine ID {id} è {order_status}.", + "outOfRangeFiatAmount": "L'importo richiesto è errato e potrebbe essere fuori dai limiti accettabili. Il limite minimo è {min_amount} e il limite massimo è {max_amount}.", + "outOfRangeSatsAmount": "L'importo consentito per gli ordini di questo Mostro è compreso tra min {min_order_amount} e max {max_order_amount} Sats. Inserisci un importo all'interno di questo range.", + "isNotYourDispute": "Questa disputa non è assegnata a te!", + "disputeCreationError": "Non è possibile avviare una disputa per questo ordine.", + "notFound": "Disputa non trovata.", + "invalidDisputeStatus": "Lo stato della disputa è invalido.", + "invalidAction": "L'azione richiesta è invalida", + "pendingOrderExists": "Esiste già un ordine in attesa." +} From 998ff24f0f7d2219a042abc05b47d1931ba6f49c Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 16 Apr 2025 13:13:13 +1000 Subject: [PATCH 091/149] added background services --- android/app/build.gradle | 12 +- android/app/src/main/AndroidManifest.xml | 4 + .../abstract_background_service.dart | 7 + lib/background/background.dart | 78 ++++++++ lib/background/background_service.dart | 13 ++ .../background_status_provider.dart | 3 + .../desktop_background_service.dart | 91 +++++++++ lib/background/mobile_background_service.dart | 52 +++++ lib/core/app.dart | 39 +++- lib/core/config.dart | 10 +- lib/data/models.dart | 16 ++ lib/data/repositories.dart | 2 + lib/data/repositories/base_storage.dart | 9 + .../chat/notifiers/chat_room_notifier.dart | 60 +++--- lib/features/settings/about_screen.dart | 148 +++++++++----- lib/features/settings/settings_notifier.dart | 5 +- lib/main.dart | 17 +- lib/notifications/notification_service.dart | 58 ++++++ lib/services/mostro_service.dart | 118 +++++------ lib/services/nostr_service.dart | 16 +- lib/shared/notifiers/session_notifier.dart | 32 +-- lib/shared/providers/app_init_provider.dart | 4 +- .../background_service_provider.dart | 6 + .../local_notifications_providers.dart | 7 + .../providers/mostro_database_provider.dart | 1 - .../providers/mostro_service_provider.dart | 5 +- .../providers/nostr_service_provider.dart | 3 +- lib/shared/providers/providers.dart | 2 + lib/shared/utils/tray_manager.dart | 51 +++++ lib/shared/widgets/responsive_button.dart | 95 +++++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 185 +++++++++++++----- pubspec.yaml | 5 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 2 + 37 files changed, 924 insertions(+), 244 deletions(-) create mode 100644 lib/background/abstract_background_service.dart create mode 100644 lib/background/background.dart create mode 100644 lib/background/background_service.dart create mode 100644 lib/background/background_status_provider.dart create mode 100644 lib/background/desktop_background_service.dart create mode 100644 lib/background/mobile_background_service.dart create mode 100644 lib/data/models.dart create mode 100644 lib/data/repositories.dart create mode 100644 lib/notifications/notification_service.dart create mode 100644 lib/shared/providers/background_service_provider.dart create mode 100644 lib/shared/providers/local_notifications_providers.dart create mode 100644 lib/shared/providers/providers.dart create mode 100644 lib/shared/utils/tray_manager.dart create mode 100644 lib/shared/widgets/responsive_button.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 9ba0ff47..65a0b31f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -11,12 +11,14 @@ android { ndkVersion = "26.3.11579264" //flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true + + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 + jvmTarget = "11" } defaultConfig { @@ -42,3 +44,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b9ec7241..c3897d2a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,10 @@ + + initialize(Settings settings); + void subscribe(Map filter); + void setForegroundStatus(bool isForeground); +} diff --git a/lib/background/background.dart b/lib/background/background.dart new file mode 100644 index 00000000..797b873f --- /dev/null +++ b/lib/background/background.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'dart:ui'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/data/repositories/event_storage.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/notifications/notification_service.dart'; +import 'package:mostro_mobile/services/nostr_service.dart'; +import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; + +bool isAppForeground = false; + +@pragma('vm:entry-point') +Future serviceMain(ServiceInstance service) async { + // If on Android, set up a permanent notification so the OS won't kill it. + if (service is AndroidServiceInstance) { + service.setAsForegroundService(); + const androidDetails = AndroidNotificationDetails( + 'mostro_foreground', + 'Mostro Foreground Service', + icon: '@mipmap/ic_launcher', + priority: Priority.high, + importance: Importance.max, + ); + + const notificationDetails = NotificationDetails( + android: androidDetails, + ); + + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + await flutterLocalNotificationsPlugin.show( + Config.notificationId, + 'Mostro is running', + 'Connected to Nostr...', + notificationDetails, + ); + } + + final nostrService = NostrService(); + final db = await openMostroDatabase(); + final backgroundStorage = EventStorage(db: db); + + service.on('app-foreground-status').listen((data) { + isAppForeground = data?['isForeground'] ?? false; + }); + + service.on('settings-change').listen((data) async { + await nostrService.init( + Settings.fromJson(data!['settings']), + ); + }); + + // Listen for commands from the main isolate + service.on('create-subscription').listen((data) { + final filter = NostrFilter.fromJson(data?['filter']); + final subscription = nostrService.subscribeToEvents(filter); + subscription.listen((event) async { + await backgroundStorage.putItem( + event.subscriptionId!, + event, + ); + if (!isAppForeground) { + await showLocalNotification(event); + } + }); + }); +} + +@pragma('vm:entry-point') +Future onIosBackground(ServiceInstance service) async { + WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); + + return true; +} diff --git a/lib/background/background_service.dart b/lib/background/background_service.dart new file mode 100644 index 00000000..0bce37ac --- /dev/null +++ b/lib/background/background_service.dart @@ -0,0 +1,13 @@ +import 'dart:io'; +import 'package:mostro_mobile/background/abstract_background_service.dart'; +import 'package:mostro_mobile/background/desktop_background_service.dart'; +import 'package:mostro_mobile/background/mobile_background_service.dart'; + + +BackgroundService createBackgroundService() { + if (Platform.isAndroid || Platform.isIOS) { + return MobileBackgroundService(); + } else { + return DesktopBackgroundService(); + } +} diff --git a/lib/background/background_status_provider.dart b/lib/background/background_status_provider.dart new file mode 100644 index 00000000..7eb544d2 --- /dev/null +++ b/lib/background/background_status_provider.dart @@ -0,0 +1,3 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final isAppInForegroundProvider = StateProvider((_) => true); diff --git a/lib/background/desktop_background_service.dart b/lib/background/desktop_background_service.dart new file mode 100644 index 00000000..992f7a2e --- /dev/null +++ b/lib/background/desktop_background_service.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/repositories.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/notifications/notification_service.dart'; +import 'package:mostro_mobile/services/nostr_service.dart'; +import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; +import 'abstract_background_service.dart'; + +class DesktopBackgroundService implements BackgroundService { + late SendPort _sendPort; + + @override + Future initialize(Settings settings) async { + final receivePort = ReceivePort(); + await Isolate.spawn(_isolateEntry, receivePort.sendPort); + _sendPort = await receivePort.first as SendPort; + + _sendPort.send({ + 'command': 'settings-change', + 'settings': settings.toJson(), + }); + + } + + static void _isolateEntry(SendPort mainSendPort) async { + final isolateReceivePort = ReceivePort(); + mainSendPort.send(isolateReceivePort.sendPort); + + final nostrService = NostrService(); + final db = await openMostroDatabase(); + final backgroundStorage = EventStorage(db: db); + final logger = Logger(); + bool isAppForeground = false; + + isolateReceivePort.listen((message) async { + if (message is! Map || message['command'] == null) return; + + final command = message['command']; + + switch (command) { + case 'settings-change': + await nostrService.updateSettings( + Settings.fromJson(message['settings']), + ); + case 'subscribe': + final pList = message['filter']['#p']; + List? p = pList != null ? [pList[0]] : null; + + final filter = NostrFilter( + kinds: message['filter']['kinds'], + p: p, + ); + + final subscription = nostrService.subscribeToEvents(filter); + + subscription.listen((event) async { + await backgroundStorage.putItem( + event.subscriptionId!, + event, + ); + if (!isAppForeground) { + await showLocalNotification(event); + } + }); + case 'app-foreground-status': + isAppForeground = message['isForeground']; + default: + logger.i('Unknown command: $command'); + } + }); + } + + @override + void subscribe(Map filter) { + _sendPort.send({ + 'command': 'subscribe', + 'filter': filter, + }); + } + + @override + void setForegroundStatus(bool isForeground) { + _sendPort.send({ + 'command': 'app-foreground-status', + 'isForeground': isForeground, + }); + } +} diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart new file mode 100644 index 00000000..b9cb911d --- /dev/null +++ b/lib/background/mobile_background_service.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:mostro_mobile/background/background.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; +import 'abstract_background_service.dart'; + +class MobileBackgroundService implements BackgroundService { + final _eventsController = StreamController>.broadcast(); + final service = FlutterBackgroundService(); + + @override + Future initialize(Settings settings) async { + await service.configure( + iosConfiguration: IosConfiguration( + autoStart: true, + onForeground: serviceMain, + onBackground: onIosBackground, + ), + androidConfiguration: AndroidConfiguration( + autoStart: true, + onStart: serviceMain, + isForegroundMode: false, + autoStartOnBoot: true, + ), + ); + + service.invoke( + 'settings-change', + settings.toJson(), + ); + + service.on('nostr-event').listen((data) { + _eventsController.add(data!); + }); + } + + @override + void subscribe(Map filter) { + service.invoke( + 'create-subscription', + {'filter': filter}, + ); + } + + @override + void setForegroundStatus(bool isForeground) { + service.invoke('app-foreground-status', { + 'isForeground': isForeground, + }); + } +} diff --git a/lib/core/app.dart b/lib/core/app.dart index 69ff51d1..4818dfb8 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -8,15 +8,42 @@ import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dar import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/features/auth/notifiers/auth_state.dart'; import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; +import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; -class MostroApp extends ConsumerWidget { +class MostroApp extends ConsumerStatefulWidget { const MostroApp({super.key}); static final GlobalKey navigatorKey = GlobalKey(); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _MostroAppState(); +} + +class _MostroAppState extends ConsumerState + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final isForeground = state == AppLifecycleState.resumed; + final backgroundService = ref.read(backgroundServiceProvider); + backgroundService.setForegroundStatus(isForeground); + + } + + @override + Widget build(BuildContext context) { final initAsyncValue = ref.watch(appInitializerProvider); return initAsyncValue.when( @@ -24,9 +51,11 @@ class MostroApp extends ConsumerWidget { ref.listen(authNotifierProvider, (previous, state) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; - if (state is AuthAuthenticated || state is AuthRegistrationSuccess) { + if (state is AuthAuthenticated || + state is AuthRegistrationSuccess) { context.go('/'); - } else if (state is AuthUnregistered || state is AuthUnauthenticated) { + } else if (state is AuthUnregistered || + state is AuthUnauthenticated) { context.go('/'); } }); @@ -51,7 +80,7 @@ class MostroApp extends ConsumerWidget { darkTheme: AppTheme.theme, home: Scaffold( backgroundColor: AppTheme.dark1, - body: Center(child: CircularProgressIndicator()), + body: const Center(child: CircularProgressIndicator()), ), ), error: (err, stack) => MaterialApp( diff --git a/lib/core/config.dart b/lib/core/config.dart index bb105062..3333f4f0 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -4,15 +4,15 @@ class Config { // Configuración de Nostr static const List nostrRelays = [ 'wss://relay.mostro.network', - //'ws://127.0.0.1:7000', + //'ws://127.0.0.1:7000', //'ws://192.168.1.103:7000', //'ws://10.0.2.2:7000', // mobile emulator ]; // hexkey de Mostro static const String mostroPubKey = - '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; - //'9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + //'9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; static const String dBName = 'mostro.db'; static const String dBPassword = 'mostro'; @@ -30,4 +30,8 @@ class Config { static int expirationSeconds = 900; static int expirationHours = 24; + + // Configuración de notificaciones + static String notificationChannelId = 'mostro_mobile'; + static int notificationId = 38383; } diff --git a/lib/data/models.dart b/lib/data/models.dart new file mode 100644 index 00000000..338f713d --- /dev/null +++ b/lib/data/models.dart @@ -0,0 +1,16 @@ +export 'package:mostro_mobile/data/models/amount.dart'; +export 'package:mostro_mobile/data/models/cant_do.dart'; +export 'package:mostro_mobile/data/models/dispute.dart'; +export 'package:mostro_mobile/data/models/mostro_message.dart'; +export 'package:mostro_mobile/data/models/nostr_event.dart'; +export 'package:mostro_mobile/data/models/order.dart'; +export 'package:mostro_mobile/data/models/payload.dart'; +export 'package:mostro_mobile/data/models/payment_request.dart'; +export 'package:mostro_mobile/data/models/rating_user.dart'; +export 'package:mostro_mobile/data/models/rating.dart'; +export 'package:mostro_mobile/data/models/session.dart'; + +export 'package:mostro_mobile/data/models/enums/order_type.dart'; +export 'package:mostro_mobile/data/models/enums/role.dart'; +export 'package:mostro_mobile/data/models/enums/action.dart'; +export 'package:mostro_mobile/data/models/enums/status.dart'; \ No newline at end of file diff --git a/lib/data/repositories.dart b/lib/data/repositories.dart new file mode 100644 index 00000000..167fcb53 --- /dev/null +++ b/lib/data/repositories.dart @@ -0,0 +1,2 @@ +export 'package:mostro_mobile/data/repositories/event_storage.dart'; +export 'package:mostro_mobile/data/repositories/mostro_storage.dart'; diff --git a/lib/data/repositories/base_storage.dart b/lib/data/repositories/base_storage.dart index f242a133..8aec6006 100644 --- a/lib/data/repositories/base_storage.dart +++ b/lib/data/repositories/base_storage.dart @@ -97,6 +97,15 @@ abstract class BaseStorage { return toDelete; } + Stream> watch() { + final store = stringMapStoreFactory.store('events'); + return store.query().onSnapshots(db).map((snapshot) => snapshot + .map( + (record) => fromDbMap(record.key, record.value), + ) + .toList()); + } + /// If needed, close or clean up resources here. void dispose() {} } diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index 50b171a4..f34ee812 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; @@ -14,32 +16,44 @@ class ChatRoomNotifier extends StateNotifier { final Ref ref; late StreamSubscription subscription; - ChatRoomNotifier(super.state, this.orderId, this.ref); + ChatRoomNotifier( + super.state, + this.orderId, + this.ref, + ); + + Future init() async { + final eventStore = ref.read(eventStorageProvider); + eventStore.watch().listen((data) { + data.forEach(_handleIncomingEvent); + }); + } + + void _handleIncomingEvent(NostrEvent event) async { + final session = ref.read(sessionProvider(event.subscriptionId!)); + if (session == null) return; + try { + final chat = await event.mostroUnWrap(session.sharedKey!); + if (!state.messages.contains(chat)) { + state = state.copy( + messages: [ + ...state.messages, + chat, + ], + ); + } + } catch (e) { + _logger.e(e); + } + } void subscribe() { + final backgroundService = ref.read(backgroundServiceProvider); final session = ref.read(sessionProvider(orderId)); - final filter = NostrFilter( - kinds: [1059], - p: [session!.sharedKey!.public], - ); - subscription = - ref.read(nostrServiceProvider).subscribeToEvents(filter).listen( - (event) async { - try { - final chat = await event.mostroUnWrap(session.sharedKey!); - if (!state.messages.contains(chat)) { - state = state.copy( - messages: [ - ...state.messages, - chat, - ], - ); - } - } catch (e) { - _logger.e(e); - } - }, - ); + backgroundService.subscribe({ + 'kinds' : [1059], + '#p': [session?.sharedKey!.public], + }); } Future sendMessage(String text) async { diff --git a/lib/features/settings/about_screen.dart b/lib/features/settings/about_screen.dart index 45bded11..92f45386 100644 --- a/lib/features/settings/about_screen.dart +++ b/lib/features/settings/about_screen.dart @@ -26,57 +26,92 @@ class AboutScreen extends ConsumerWidget { onPressed: () => context.pop(), ), title: Text( - 'ABOUT', + 'About', style: TextStyle( color: AppTheme.cream1, ), ), ), backgroundColor: AppTheme.dark1, - body: nostrEvent == null - ? const Center(child: CircularProgressIndicator()) - : Padding( - padding: const EdgeInsets.all(16.0), - child: Container( - decoration: BoxDecoration( - color: AppTheme.dark2, - borderRadius: BorderRadius.circular(20), - ), - child: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 24), - Center( - child: CircleAvatar( - radius: 36, - backgroundColor: Colors.grey, - foregroundImage: - AssetImage('assets/images/launcher-icon.png'), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: AppTheme.largePadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 24, + children: [ + CustomCard( + padding: const EdgeInsets.all(24), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8, + children: [ + const Icon( + Icons.content_paste, + color: AppTheme.mostroGreen, + ), + Text('App Information', + style: textTheme.titleLarge), + ], ), - ), - const SizedBox(height: 16), - Text( - 'Mostro Mobile Client', - style: textTheme.displayMedium, - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: _buildClientDetails(), - ), - Text( - 'Mostro Daemon', - style: textTheme.displayMedium, - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: _buildInstanceDetails( - MostroInstance.fromEvent(nostrEvent)), - ), - ], + Row( + spacing: 8, + children: [ + Expanded( + child: _buildClientDetails(), + ), + ], + ), + ], + ), + ), + CustomCard( + color: AppTheme.dark2, + padding: const EdgeInsets.all(16), + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8, + children: [ + const Icon( + Icons.content_paste, + color: AppTheme.mostroGreen, + ), + Text('About Mostro Instance', + style: textTheme.titleLarge), + ], + ), + Text('General Info', + style: textTheme.titleMedium?.copyWith( + color: AppTheme.mostroGreen, + )), + nostrEvent == null + ? const Center(child: CircularProgressIndicator()) + : Row( + spacing: 8, + children: [ + Expanded( + child: _buildInstanceDetails( + MostroInstance.fromEvent(nostrEvent)), + ), + ], + ), + ], + ), ), - ), + ], ), ), + ) + ], + ), ); } @@ -88,20 +123,32 @@ class AboutScreen extends ConsumerWidget { String.fromEnvironment('GIT_COMMIT', defaultValue: 'N/A'); return CustomCard( - color: AppTheme.dark1, - padding: EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Version', + ), + Text( + appVersion, + ), + const SizedBox(height: 8), + Row( children: [ Text( - 'Version: $appVersion', - ), - const SizedBox(height: 3), - Text( - 'Commit Hash: $gitCommit', + 'GitHub Repository', ), ], - )); + ), + const SizedBox(height: 8), + Text( + 'Commit Hash', + ), + Text( + gitCommit, + ), + ], + )); } /// Builds the header displaying details from the MostroInstance. @@ -109,7 +156,6 @@ class AboutScreen extends ConsumerWidget { final formatter = NumberFormat.decimalPattern(Intl.getCurrentLocale()); return CustomCard( - color: AppTheme.dark1, padding: EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/settings/settings_notifier.dart b/lib/features/settings/settings_notifier.dart index a12fa5b7..55107834 100644 --- a/lib/features/settings/settings_notifier.dart +++ b/lib/features/settings/settings_notifier.dart @@ -23,8 +23,7 @@ class SettingsNotifier extends StateNotifier { final settingsJson = await _prefs.getString(_storageKey); if (settingsJson != null) { try { - final loaded = Settings.fromJson(jsonDecode(settingsJson)); - state = loaded; + state = Settings.fromJson(jsonDecode(settingsJson)); } catch (_) { state = _defaultSettings(); } @@ -57,4 +56,6 @@ class SettingsNotifier extends StateNotifier { final jsonString = jsonEncode(state.toJson()); await _prefs.setString(_storageKey, jsonString); } + + Settings get settings => state.copyWith(); } diff --git a/lib/main.dart b/lib/main.dart index 8ee75534..5039fbbb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,14 +5,16 @@ import 'package:mostro_mobile/core/app.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/features/settings/settings_notifier.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; -import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; -import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'package:mostro_mobile/background/background_service.dart'; +import 'package:mostro_mobile/notifications/notification_service.dart'; +import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/providers.dart'; import 'package:mostro_mobile/shared/utils/biometrics_helper.dart'; import 'package:shared_preferences/shared_preferences.dart'; -void main() async { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); - + final biometricsHelper = BiometricsHelper(); final sharedPreferences = SharedPreferencesAsync(); final secureStorage = const FlutterSecureStorage(); @@ -21,10 +23,16 @@ void main() async { final settings = SettingsNotifier(sharedPreferences); await settings.init(); + await initializeNotifications(); + + final backgroundService = createBackgroundService(); + await backgroundService.initialize(settings.settings); + runApp( ProviderScope( overrides: [ settingsProvider.overrideWith((b) => settings), + backgroundServiceProvider.overrideWithValue(backgroundService), biometricsHelperProvider.overrideWithValue(biometricsHelper), sharedPreferencesProvider.overrideWithValue(sharedPreferences), secureStorageProvider.overrideWithValue(secureStorage), @@ -34,3 +42,4 @@ void main() async { ), ); } + diff --git a/lib/notifications/notification_service.dart b/lib/notifications/notification_service.dart new file mode 100644 index 00000000..030bac6e --- /dev/null +++ b/lib/notifications/notification_service.dart @@ -0,0 +1,58 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + +Future initializeNotifications() async { + const android = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); + const ios = DarwinInitializationSettings(); + + const linux = LinuxInitializationSettings( + defaultActionName: 'Open', + ); + + const initSettings = InitializationSettings( + android: android, + iOS: ios, + linux: linux, + ); + final plugin = FlutterLocalNotificationsPlugin(); + await plugin.initialize(initSettings); +} + +Future showNotification( + int id, String title, String body, String payload) async { + await flutterLocalNotificationsPlugin.show( + id, + title, + body, + NotificationDetails( + android: AndroidNotificationDetails( + 'mostro_channel', + 'Mostro Notifications', + importance: Importance.max, + )), + payload: payload, + ); +} + +Future showLocalNotification(NostrEvent event) async { + final notificationsPlugin = FlutterLocalNotificationsPlugin(); + const details = NotificationDetails( + android: AndroidNotificationDetails( + 'mostro_notifications', + 'Mostro Notifications', + importance: Importance.max, + ), + ); + await notificationsPlugin.show( + event.createdAt?.millisecondsSinceEpoch ?? + DateTime.now().millisecondsSinceEpoch, + 'New Mostro Event', + 'Action: ${event.kind}', + details, + ); +} diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 549e37fd..86b9b94b 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,98 +1,75 @@ import 'dart:convert'; -import 'package:dart_nostr/dart_nostr.dart'; +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/amount.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart' as actions; -import 'package:mostro_mobile/data/models/enums/order_type.dart'; -import 'package:mostro_mobile/data/models/enums/role.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart'; -import 'package:mostro_mobile/data/models/nostr_event.dart'; -import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/data/models/payment_request.dart'; -import 'package:mostro_mobile/data/models/rating_user.dart'; -import 'package:mostro_mobile/data/models/session.dart'; -import 'package:mostro_mobile/data/repositories/event_storage.dart'; -import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; +import 'package:mostro_mobile/background/abstract_background_service.dart'; +import 'package:mostro_mobile/data/models.dart'; +import 'package:mostro_mobile/data/repositories.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/event_bus.dart'; -import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; class MostroService { final Ref ref; - final NostrService _nostrService; final SessionNotifier _sessionNotifier; final EventStorage _eventStorage; final MostroStorage _messageStorage; - final EventBus _bus; Settings _settings; + final BackgroundService backgroundService; + MostroService( this._sessionNotifier, this._eventStorage, this._bus, this._messageStorage, this.ref, - ) : _nostrService = ref.read(nostrServiceProvider), - _settings = ref.read(settingsProvider); + this.backgroundService, + ) : _settings = ref.read(settingsProvider); - void subscribe(Session session) { - final filter = NostrFilter( - kinds: [1059], - p: [session.tradeKey.public], - ); + Future init() async { + _eventStorage.watch().listen((data) { + data.forEach(_handleIncomingEvent); + }); + } + + void _handleIncomingEvent(NostrEvent event) async { + + final currentSession = getSessionByOrderId(event.subscriptionId!); + if (currentSession == null) return; + + // Process event as you currently do: + final decryptedEvent = await event.unWrap(currentSession.tradeKey.private); + if (decryptedEvent.content == null) return; + + final result = jsonDecode(decryptedEvent.content!); + if (result is! List) return; + + final msg = MostroMessage.fromJson(result[0]); + if (msg.id != null) { + ref.read(orderActionNotifierProvider(msg.id!).notifier).set(msg.action); + } + if (msg.action == Action.canceled) { + await _messageStorage.deleteAllMessagesById(currentSession.orderId!); + await _sessionNotifier.deleteSession(currentSession.orderId!); + return; + } + await _messageStorage.addMessage(msg); + if (currentSession.orderId == null && msg.id != null) { + currentSession.orderId = msg.id; + await _sessionNotifier.saveSession(currentSession); + } + _bus.emit(msg); + } - _nostrService.subscribeToEvents(filter).listen((event) async { - // The item has already beeen processed - if (await _eventStorage.hasItem(event.id!)) return; - // Store the event - await _eventStorage.putItem( - event.id!, - event, - ); - - final decryptedEvent = await event.unWrap( - session.tradeKey.private, - ); - if (decryptedEvent.content == null) return; - - final result = jsonDecode(decryptedEvent.content!); - if (result is! List) return; - - final msgMap = result[0]; - - final msg = MostroMessage.fromJson(msgMap); - - if (msg.id != null) { - ref - .read( - orderActionNotifierProvider(msg.id!).notifier, - ) - .set( - msg.action, - ); - } - - if (msg.action == actions.Action.canceled) { - await _messageStorage.deleteAllMessagesById(session.orderId!); - await _sessionNotifier.deleteSession(session.orderId!); - return; - } - - await _messageStorage.addMessage(msg); - - if (session.orderId == null && msg.id != null) { - session.orderId = msg.id; - await _sessionNotifier.saveSession(session); - } - - _bus.emit(msg); + void subscribe(Session session) { + backgroundService.subscribe({ + 'kinds': [1059], + '#p': [session.tradeKey.public], }); } @@ -207,7 +184,8 @@ class MostroService { masterKey: session.fullPrivacy ? null : session.masterKey, keyIndex: session.fullPrivacy ? null : session.keyIndex, ); - await _nostrService.publishEvent(event); + + ref.read(nostrServiceProvider).publishEvent(event); return session; } diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 742a57e9..7d9a6ea2 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -7,15 +7,16 @@ import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class NostrService { - Settings settings; + late Settings settings; final Nostr _nostr = Nostr.instance; - NostrService(this.settings); + NostrService(); final Logger _logger = Logger(); bool _isInitialized = false; - Future init() async { + Future init(Settings settings) async { + this.settings = settings; try { await _nostr.services.relays.init( relaysUrl: settings.relays, @@ -42,11 +43,10 @@ class NostrService { } Future updateSettings(Settings newSettings) async { - settings = newSettings.copyWith(); final relays = Nostr.instance.services.relays.relaysList; - if (!ListEquality().equals(relays, settings.relays)) { + if (!ListEquality().equals(relays, newSettings.relays)) { _logger.i('Updating relays...'); - await init(); + await init(newSettings); } } @@ -79,12 +79,10 @@ class NostrService { } final request = NostrRequest(filters: [filter]); - return - await _nostr.services.relays.startEventsSubscriptionAsync( + return await _nostr.services.relays.startEventsSubscriptionAsync( request: request, timeout: Config.nostrConnectionTimeout, ); - } Stream subscribeToEvents(NostrFilter filter) { diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index 99ac7a68..575d2e34 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -8,24 +8,19 @@ import 'package:mostro_mobile/features/key_manager/key_manager.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; class SessionNotifier extends StateNotifier> { - // Dependencies final Logger _logger = Logger(); final KeyManager _keyManager; final SessionStorage _storage; - // Current settings Settings _settings; - // In-memory session cache, keyed by `session.keyIndex`. final Map _sessions = {}; - // Cleanup / expiration logic Timer? _cleanupTimer; static const int sessionExpirationHours = 36; static const int cleanupIntervalMinutes = 30; static const int maxBatchSize = 100; - /// Public getter to expose sessions (if needed) List get sessions => _sessions.values.toList(); SessionNotifier( @@ -37,23 +32,18 @@ class SessionNotifier extends StateNotifier> { //_initializeCleanup(); } - /// Initialize by loading all sessions from DB into memory, then updating state. Future init() async { final allSessions = await _storage.getAllSessions(); for (final session in allSessions) { _sessions[session.orderId!] = session; } - // Update the notifier state with fresh data. state = sessions; } - /// Update the application settings if needed. void updateSettings(Settings settings) { _settings = settings.copyWith(); - // You might want to refresh or do something else if settings impact sessions. } - /// Creates a new session, storing it both in memory and in the database. Future newSession({String? orderId, Role? role}) async { final masterKey = _keyManager.masterKeyPair!; final keyIndex = await _keyManager.getCurrentKeyIndex(); @@ -69,11 +59,8 @@ class SessionNotifier extends StateNotifier> { role: role, ); - // Cache it in memory - if (orderId != null) { _sessions[orderId] = session; - // Persist it to DB await _storage.putSession(session); state = sessions; } else { @@ -82,14 +69,12 @@ class SessionNotifier extends StateNotifier> { return session; } - /// Updates a session in both memory and database. Future saveSession(Session session) async { _sessions[session.orderId!] = session; await _storage.putSession(session); state = sessions; } - /// Retrieve the first session whose `orderId` matches [orderId]. Session? getSessionByOrderId(String orderId) { try { return _sessions[orderId]; @@ -98,20 +83,26 @@ class SessionNotifier extends StateNotifier> { } } - /// Retrieve a session by its keyIndex (checks memory first, then DB). + Future getSessionByTradeKey(String tradeKey) async { + final sessions = await _storage.getAllSessions(); + return sessions.firstWhere( + (s) => s.tradeKey.public == tradeKey, + ); + } + Future loadSession(int keyIndex) async { final sessions = await _storage.getAllSessions(); - return sessions.firstWhere((s) => s.keyIndex == keyIndex); + return sessions.firstWhere( + (s) => s.keyIndex == keyIndex, + ); } - /// Resets all stored sessions by clearing DB and memory. Future reset() async { await _storage.deleteAllItems(); _sessions.clear(); state = []; } - /// Deletes a session from memory and DB. Future deleteSession(String sessionId) async { _sessions.remove(sessionId); await _storage.deleteSession(sessionId); @@ -126,8 +117,6 @@ class SessionNotifier extends StateNotifier> { maxBatchSize, ); for (final id in removedIds) { - // If your underlying session keys are strings, - // you may have to parse them to int or store them as-is. _sessions.remove(id); } state = sessions; @@ -148,7 +137,6 @@ class SessionNotifier extends StateNotifier> { ); } - /// Dispose resources (like timers) when no longer needed. @override void dispose() { _cleanupTimer?.cancel(); diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 7fea1a94..dca308b1 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -16,7 +16,7 @@ import 'package:shared_preferences/shared_preferences.dart'; final appInitializerProvider = FutureProvider((ref) async { final nostrService = ref.read(nostrServiceProvider); - await nostrService.init(); + await nostrService.init(ref.read(settingsProvider)); final keyManager = ref.read(keyManagerProvider); await keyManager.init(); @@ -44,6 +44,7 @@ final appInitializerProvider = FutureProvider((ref) async { ref.read( orderNotifierProvider(session.orderId!), ); + await mostroService.init(); mostroService.subscribe(session); } @@ -51,6 +52,7 @@ final appInitializerProvider = FutureProvider((ref) async { final chat = ref.watch( chatRoomsProvider(session.orderId!).notifier, ); + await chat.init(); chat.subscribe(); } } diff --git a/lib/shared/providers/background_service_provider.dart b/lib/shared/providers/background_service_provider.dart new file mode 100644 index 00000000..d493a59d --- /dev/null +++ b/lib/shared/providers/background_service_provider.dart @@ -0,0 +1,6 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/background/abstract_background_service.dart'; + +final backgroundServiceProvider = Provider((ref) { + throw UnimplementedError(); +}); diff --git a/lib/shared/providers/local_notifications_providers.dart b/lib/shared/providers/local_notifications_providers.dart new file mode 100644 index 00000000..caa0d9f3 --- /dev/null +++ b/lib/shared/providers/local_notifications_providers.dart @@ -0,0 +1,7 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final localNotificationsProvider = Provider((ref) { + return FlutterLocalNotificationsPlugin(); +}); + diff --git a/lib/shared/providers/mostro_database_provider.dart b/lib/shared/providers/mostro_database_provider.dart index 866661d4..694826fc 100644 --- a/lib/shared/providers/mostro_database_provider.dart +++ b/lib/shared/providers/mostro_database_provider.dart @@ -2,7 +2,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tekartik_app_flutter_sembast/sembast.dart'; Future openMostroDatabase() async { - var factory = getDatabaseFactory(); final db = await factory.openDatabase('mostro.db'); return db; diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index 4724f5c0..5d107d46 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/repositories/event_storage.dart'; import 'package:mostro_mobile/services/event_bus.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; +import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; @@ -20,12 +21,14 @@ MostroService mostroService(Ref ref) { final eventStore = ref.read(eventStorageProvider); final eventBus = ref.read(eventBusProvider); final mostroDatabase = ref.read(mostroStorageProvider); + final backgroundService = ref.read(backgroundServiceProvider); final mostroService = MostroService( sessionStorage, eventStore, eventBus, mostroDatabase, - ref + ref, + backgroundService, ); return mostroService; } diff --git a/lib/shared/providers/nostr_service_provider.dart b/lib/shared/providers/nostr_service_provider.dart index f44fb50a..a481a407 100644 --- a/lib/shared/providers/nostr_service_provider.dart +++ b/lib/shared/providers/nostr_service_provider.dart @@ -4,8 +4,7 @@ import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; final nostrServiceProvider = Provider((ref) { - final settings = ref.read(settingsProvider); - final nostrService = NostrService(settings); + final nostrService = NostrService(); ref.listen(settingsProvider, (previous, next) { nostrService.updateSettings(next); diff --git a/lib/shared/providers/providers.dart b/lib/shared/providers/providers.dart new file mode 100644 index 00000000..851ff56b --- /dev/null +++ b/lib/shared/providers/providers.dart @@ -0,0 +1,2 @@ +export 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; +export 'package:mostro_mobile/shared/providers/storage_providers.dart'; diff --git a/lib/shared/utils/tray_manager.dart b/lib/shared/utils/tray_manager.dart new file mode 100644 index 00000000..18d70464 --- /dev/null +++ b/lib/shared/utils/tray_manager.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:system_tray/system_tray.dart'; +import 'package:flutter/material.dart'; + +class TrayManager { + static final TrayManager _instance = TrayManager._internal(); + factory TrayManager() => _instance; + + final SystemTray _tray = SystemTray(); + + TrayManager._internal(); + + Future init(GlobalKey navigatorKey) async { + const iconPath = 'assets/images/launcher-icon.png'; + + await _tray.initSystemTray( + iconPath: iconPath, + toolTip: "Mostro is running", + title: '', + ); + + final menu = Menu(); + + menu.buildFrom([ + MenuItemLabel( + label: 'Open Mostro', + onClicked: (menuItem) { + navigatorKey.currentState?.pushNamed('/'); + }, + ), + MenuItemLabel( + label: 'Quit', + onClicked: (menuItem) { + _tray.destroy(); + Future.delayed(const Duration(milliseconds: 300), () { + exit(0); + }); + }), + ]); + + await _tray.setContextMenu(menu); + + // Handle tray icon click (e.g., double click = open) + _tray.registerSystemTrayEventHandler((eventName) { + if (eventName == kSystemTrayEventClick) { + navigatorKey.currentState?.pushNamed('/'); + } + }); + } +} diff --git a/lib/shared/widgets/responsive_button.dart b/lib/shared/widgets/responsive_button.dart new file mode 100644 index 00000000..69609fe0 --- /dev/null +++ b/lib/shared/widgets/responsive_button.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum ButtonStyleType { raised, outlined } + +class ResponsiveButton extends ConsumerStatefulWidget { + final String label; + final ButtonStyleType buttonStyle; + final VoidCallback onPressed; + final AsyncNotifierProvider>, AsyncValue> + listenTo; + final Duration timeout; + final String errorSnackbarMessage; + + const ResponsiveButton({ + super.key, + required this.label, + required this.buttonStyle, + required this.onPressed, + required this.listenTo, + this.timeout = const Duration(seconds: 5), + this.errorSnackbarMessage = 'Something went wrong.', + }); + + @override + ConsumerState createState() => _ResponsiveButtonState(); +} + +class _ResponsiveButtonState extends ConsumerState> { + bool _loading = false; + late final VoidCallback _listener; + + @override + void initState() { + super.initState(); + _listener = () { + final state = ref.read(widget.listenTo); + if (_loading) { + if (state is AsyncData) { + setState(() => _loading = false); + } else if (state is AsyncError) { + _showError(); + } + } + }; + } + + void _showError() { + setState(() => _loading = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(widget.errorSnackbarMessage)), + ); + } + } + + Future _handlePress() async { + setState(() => _loading = true); + widget.onPressed(); + + // Start timeout countdown + Future.delayed(widget.timeout, () { + final state = ref.read(widget.listenTo); + if (_loading && state is! AsyncData) { + _showError(); + } + }); + } + + @override + Widget build(BuildContext context) { + ref.listen(widget.listenTo, (_, __) => _listener()); + + final child = _loading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(widget.label); + + final button = switch (widget.buttonStyle) { + ButtonStyleType.raised => ElevatedButton( + onPressed: _loading ? null : _handlePress, + child: child, + ), + ButtonStyleType.outlined => OutlinedButton( + onPressed: _loading ? null : _handlePress, + child: child, + ), + }; + + return SizedBox(height: 48, child: button); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f797..54a1276e 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#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); + g_autoptr(FlPluginRegistrar) system_tray_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SystemTrayPlugin"); + system_tray_plugin_register_with_registrar(system_tray_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b29e9ba0..72b5a632 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + system_tray ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ea00013e..c48f6d99 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,16 +5,20 @@ import FlutterMacOS import Foundation +import flutter_local_notifications import flutter_secure_storage_darwin import local_auth_darwin import path_provider_foundation import shared_preferences_foundation import sqflite_darwin +import system_tray func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index dd62c244..d9ca5d7c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,31 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57 + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "80.0.0" + version: "76.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e" + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "6.11.0" analyzer_plugin: dependency: transitive description: name: analyzer_plugin - sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4 + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.3" archive: dependency: transitive description: @@ -209,6 +214,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" cli_util: dependency: transitive description: @@ -253,10 +266,10 @@ packages: dependency: transitive description: name: coverage - sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 + sha256: "9086475ef2da7102a0c0a4e37e1e30707e7fb7b6d28c209f559a9c5f8ce42016" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.0" crypto: dependency: "direct main" description: @@ -285,18 +298,10 @@ packages: dependency: transitive description: name: custom_lint_core - sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + sha256: "76a4046cc71d976222a078a8fd4a65e198b70545a8d690a75196dd14f08510f6" url: "https://pub.dev" source: hosted - version: "0.7.5" - custom_lint_visitor: - dependency: transitive - description: - name: custom_lint_visitor - sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8" - url: "https://pub.dev" - source: hosted - version: "1.0.0+7.3.0" + version: "0.6.10" dart_flutter_team_lints: dependency: transitive description: @@ -309,18 +314,26 @@ packages: dependency: "direct main" description: name: dart_nostr - sha256: "13ef2c7b45ad3bb52448098c7ef517ba8f70e563e17fba17d3c26e6104bdf0c1" + sha256: d7ffb5159be6ab174c8af4457d5112c925eb2c2294ce1251707ae680c90e15db url: "https://pub.dev" source: hosted - version: "9.1.0" + version: "9.1.1" dart_style: dependency: transitive description: name: dart_style - sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "2.3.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" dev_build: dependency: transitive description: @@ -390,6 +403,38 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_background_service: + dependency: "direct main" + description: + name: flutter_background_service + sha256: "70a1c185b1fa1a44f8f14ecd6c86f6e50366e3562f00b2fa5a54df39b3324d3d" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + flutter_background_service_android: + dependency: transitive + description: + name: flutter_background_service_android + sha256: b73d903056240e23a5c56d9e52d3a5d02ae41cb18b2988a97304ae37b2bae4bf + url: "https://pub.dev" + source: hosted + version: "6.3.0" + flutter_background_service_ios: + dependency: transitive + description: + name: flutter_background_service_ios + sha256: "6037ffd45c4d019dab0975c7feb1d31012dd697e25edc05505a4a9b0c7dc9fba" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + flutter_background_service_platform_interface: + dependency: transitive + description: + name: flutter_background_service_platform_interface + sha256: ca74aa95789a8304f4d3f57f07ba404faa86bed6e415f83e8edea6ad8b904a41 + url: "https://pub.dev" + source: hosted + version: "5.1.2" flutter_driver: dependency: transitive description: flutter @@ -475,6 +520,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "33b3e0269ae9d51669957a923f2376bee96299b09915d856395af8c4238aebfa" + url: "https://pub.dev" + source: hosted + version: "19.1.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "2569b973fc9d1f63a37410a9f7c1c552081226c597190cb359ef5d5762d1631c" + url: "https://pub.dev" + source: hosted + version: "9.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: f8fc0652a601f83419d623c85723a3e82ad81f92b33eaa9bcc21ea1b94773e6e + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -566,10 +643,10 @@ packages: dependency: transitive description: name: freezed_annotation - sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "2.4.4" frontend_server_client: dependency: transitive description: @@ -820,6 +897,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + macros: + dependency: transitive + description: + name: macros + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + url: "https://pub.dev" + source: hosted + version: "0.1.3-main.0" matcher: dependency: transitive description: @@ -1057,10 +1142,10 @@ packages: dependency: transitive description: name: riverpod_analyzer_utils - sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611" + sha256: "0dcb0af32d561f8fa000c6a6d95633c9fb08ea8a8df46e3f9daca59f11218167" url: "https://pub.dev" source: hosted - version: "0.5.10" + version: "0.5.6" riverpod_annotation: dependency: "direct main" description: @@ -1073,18 +1158,18 @@ packages: dependency: "direct dev" description: name: riverpod_generator - sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36" + sha256: "851aedac7ad52693d12af3bf6d92b1626d516ed6b764eb61bf19e968b5e0b931" url: "https://pub.dev" source: hosted - version: "2.6.5" + version: "2.6.1" sembast: dependency: "direct main" description: name: sembast - sha256: "06b0274ca48ea92aedeab62303fc9ade3272684c842e9447be3e9b040f935559" + sha256: d3f0d0ba501a5f1fd7d6c8532ee01385977c8a069c334635dae390d059ae3d6d url: "https://pub.dev" source: hosted - version: "3.8.4+1" + version: "3.8.5" sembast_sqflite: dependency: transitive description: @@ -1113,10 +1198,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad" + sha256: c2c8c46297b5d6a80bed7741ec1f2759742c77d272f1a1698176ae828f8e1a18 url: "https://pub.dev" source: hosted - version: "2.4.8" + version: "2.4.9" shared_preferences_foundation: dependency: transitive description: @@ -1198,10 +1283,10 @@ packages: dependency: transitive description: name: source_gen - sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "1.5.0" source_map_stack_trace: dependency: transitive description: @@ -1226,14 +1311,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" sqflite: dependency: transitive description: @@ -1354,12 +1431,20 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" + system_tray: + dependency: "direct main" + description: + name: system_tray + sha256: "40444e5de8ed907822a98694fd031b8accc3cb3c0baa547634ce76189cf3d9cf" + url: "https://pub.dev" + source: hosted + version: "2.0.3" tekartik_app_flutter_sembast: dependency: "direct main" description: path: app_sembast ref: dart3a - resolved-ref: "485d95cc933ccf373e7b4a53c19e8f43194c66c5" + resolved-ref: e3fe710a4b9e8b6438c99da01bc430b9fcea4eeb url: "https://github.com/tekartik/app_flutter_utils.dart" source: git version: "0.5.1" @@ -1368,7 +1453,7 @@ packages: description: path: app_sqflite ref: dart3a - resolved-ref: "485d95cc933ccf373e7b4a53c19e8f43194c66c5" + resolved-ref: e3fe710a4b9e8b6438c99da01bc430b9fcea4eeb url: "https://github.com/tekartik/app_flutter_utils.dart" source: git version: "0.6.1" @@ -1430,6 +1515,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.7.0" + timezone: + dependency: transitive + description: + name: timezone + sha256: ffc9d5f4d1193534ef051f9254063fa53d588609418c84299956c3db9383587d + url: "https://pub.dev" + source: hosted + version: "0.10.0" timing: dependency: transitive description: @@ -1450,10 +1543,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "3.0.7" vector_graphics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e8c4f5bc..65118cb9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,7 +49,7 @@ dependencies: collection: ^1.18.0 elliptic: ^0.3.11 intl: ^0.19.0 - uuid: ^4.5.1 + uuid: ^3.0.7 flutter_secure_storage: ^10.0.0-beta.4 go_router: ^14.6.2 bip39: ^1.0.6 @@ -79,6 +79,9 @@ dependencies: ref: dart3a path: app_sembast version: '>=0.1.0' + flutter_local_notifications: ^19.0.0 + flutter_background_service: ^5.1.0 + system_tray: ^2.0.3 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 011734da..8e7c1bdb 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalAuthPlugin")); + SystemTrayPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SystemTrayPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 11485fce..7853b67f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,9 +5,11 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows local_auth_windows + system_tray ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES) From 1a067c1a20e931549f8ee1e75e24761d0be67c30 Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 16 Apr 2025 22:31:28 +1000 Subject: [PATCH 092/149] Added changes to background interface and upgraded MacOS settings --- android/app/src/main/AndroidManifest.xml | 34 ++--- ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 43 ++++++ .../abstract_background_service.dart | 7 +- lib/background/background.dart | 39 +++++- .../desktop_background_service.dart | 26 +++- lib/background/mobile_background_service.dart | 98 ++++++++++++-- lib/notifications/notification_service.dart | 1 + macos/Flutter/Flutter-Debug.xcconfig | 1 + macos/Flutter/Flutter-Release.xcconfig | 1 + macos/Podfile | 42 ++++++ macos/Runner.xcodeproj/project.pbxproj | 125 +++++++++++++++++- .../xcshareddata/xcschemes/Runner.xcscheme | 9 +- .../contents.xcworkspacedata | 3 + macos/Runner/AppDelegate.swift | 4 + 16 files changed, 393 insertions(+), 42 deletions(-) create mode 100644 ios/Podfile create mode 100644 macos/Podfile diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c3897d2a..e740fe12 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,10 +1,13 @@ - + + android:fullBackupContent="false" + android:enableOnBackInvokedCallback="true"> + - + android:resource="@style/NormalTheme" /> + + + - - - + + + + - \ No newline at end of file diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee8..ec97fc6f 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee8..c4855bfe 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 00000000..e549ee22 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/lib/background/abstract_background_service.dart b/lib/background/abstract_background_service.dart index 5c12ebcb..65855e72 100644 --- a/lib/background/abstract_background_service.dart +++ b/lib/background/abstract_background_service.dart @@ -2,6 +2,9 @@ import 'package:mostro_mobile/features/settings/settings.dart'; abstract class BackgroundService { Future initialize(Settings settings); - void subscribe(Map filter); + Future subscribe(Map filter); + Future unsubscribe(String subscriptionId); + Future unsubscribeAll(); + Future getActiveSubscriptionCount(); void setForegroundStatus(bool isForeground); -} +} \ No newline at end of file diff --git a/lib/background/background.dart b/lib/background/background.dart index 797b873f..5cb0b084 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -15,6 +15,36 @@ bool isAppForeground = false; @pragma('vm:entry-point') Future serviceMain(ServiceInstance service) async { + // Map to track active subscriptions + final Map> activeSubscriptions = {}; + + // Initialize service + service.on('settings-change').listen((event) { + // Initialize with settings + }); + + // Handle subscription creation + service.on('create-subscription').listen((event) { + if (event == null) return; + + final filter = event['filter'] as Map?; + final id = event['id'] as String?; + + if (filter != null && id != null) { + activeSubscriptions[id] = filter; + } + }); + + // Handle subscription cancellation + service.on('cancel-subscription').listen((event) { + if (event == null) return; + + final id = event['id'] as String?; + if (id != null && activeSubscriptions.containsKey(id)) { + activeSubscriptions.remove(id); + } + }); + // If on Android, set up a permanent notification so the OS won't kill it. if (service is AndroidServiceInstance) { service.setAsForegroundService(); @@ -55,7 +85,14 @@ Future serviceMain(ServiceInstance service) async { // Listen for commands from the main isolate service.on('create-subscription').listen((data) { - final filter = NostrFilter.fromJson(data?['filter']); + final pList = data!['filter']['#p']; + List? p = pList != null ? [pList[0]] : null; + + final filter = NostrFilter( + kinds: data['filter']['kinds'], + p: p, + ); + final subscription = nostrService.subscribeToEvents(filter); subscription.listen((event) async { await backgroundStorage.putItem( diff --git a/lib/background/desktop_background_service.dart b/lib/background/desktop_background_service.dart index 992f7a2e..70c609d7 100644 --- a/lib/background/desktop_background_service.dart +++ b/lib/background/desktop_background_service.dart @@ -10,6 +10,10 @@ import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; import 'abstract_background_service.dart'; class DesktopBackgroundService implements BackgroundService { + // Similar implementation with subscription tracking + final _subscriptions = >{}; + Isolate? _serviceIsolate; + bool _isRunning = false; late SendPort _sendPort; @override @@ -22,7 +26,6 @@ class DesktopBackgroundService implements BackgroundService { 'command': 'settings-change', 'settings': settings.toJson(), }); - } static void _isolateEntry(SendPort mainSendPort) async { @@ -74,11 +77,12 @@ class DesktopBackgroundService implements BackgroundService { } @override - void subscribe(Map filter) { + Future subscribe(Map filter) async { _sendPort.send({ 'command': 'subscribe', 'filter': filter, }); + return true; } @override @@ -88,4 +92,22 @@ class DesktopBackgroundService implements BackgroundService { 'isForeground': isForeground, }); } + + @override + Future getActiveSubscriptionCount() { + // TODO: implement getActiveSubscriptionCount + throw UnimplementedError(); + } + + @override + Future unsubscribe(String subscriptionId) { + // TODO: implement unsubscribe + throw UnimplementedError(); + } + + @override + Future unsubscribeAll() { + // TODO: implement unsubscribeAll + throw UnimplementedError(); + } } diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index b9cb911d..0a80412c 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:mostro_mobile/background/background.dart'; @@ -8,39 +9,83 @@ import 'abstract_background_service.dart'; class MobileBackgroundService implements BackgroundService { final _eventsController = StreamController>.broadcast(); final service = FlutterBackgroundService(); + final _subscriptions = >{}; + bool _isRunning = false; @override Future initialize(Settings settings) async { await service.configure( + // Keep existing configurations iosConfiguration: IosConfiguration( - autoStart: true, + autoStart: false, // Start manually only when needed onForeground: serviceMain, onBackground: onIosBackground, ), androidConfiguration: AndroidConfiguration( - autoStart: true, + autoStart: false, // Start manually only when needed onStart: serviceMain, isForegroundMode: false, - autoStartOnBoot: true, + autoStartOnBoot: false, // Let our subscription logic control this ), ); + service.on('nostr-event').listen((data) { + _eventsController.add(data!); + }); + + // Initialize with settings but don't start service.invoke( 'settings-change', settings.toJson(), ); - - service.on('nostr-event').listen((data) { - _eventsController.add(data!); - }); } @override - void subscribe(Map filter) { + Future subscribe(Map filter) async { + final subscriptionId = _generateSubscriptionId(filter); + _subscriptions[subscriptionId] = filter; + + // Start service if this is the first subscription + if (_subscriptions.length == 1 && !_isRunning) { + await _startService(); + } + + // Add subscription to the service service.invoke( 'create-subscription', - {'filter': filter}, + {'filter': filter, 'id': subscriptionId}, ); + + return true; + } + + @override + Future unsubscribe(String subscriptionId) async { + if (!_subscriptions.containsKey(subscriptionId)) { + return false; + } + + _subscriptions.remove(subscriptionId); + service.invoke('cancel-subscription', {'id': subscriptionId}); + + // If no more subscriptions, stop the service + if (_subscriptions.isEmpty && _isRunning) { + await _stopService(); + } + + return true; + } + + @override + Future unsubscribeAll() async { + for (final id in _subscriptions.keys.toList()) { + await unsubscribe(id); + } + } + + @override + Future getActiveSubscriptionCount() async { + return _subscriptions.length; } @override @@ -48,5 +93,38 @@ class MobileBackgroundService implements BackgroundService { service.invoke('app-foreground-status', { 'isForeground': isForeground, }); + + // When app goes to background but has subscriptions, + // ensure service keeps running + if (!isForeground && _subscriptions.isNotEmpty && !_isRunning) { + _startService(); + } + } + + // Helper methods + Future _startService() async { + await service.startService(); + _isRunning = true; + + // Re-register all active subscriptions + for (final entry in _subscriptions.entries) { + service.invoke( + 'create-subscription', + {'filter': entry.value, 'id': entry.key}, + ); + } + } + + Future _stopService() async { + // Use invoke pattern to request the service to stop itself + service.invoke('stopService'); + _isRunning = false; + } + + String _generateSubscriptionId(Map filter) { + // Generate a unique ID based on filter contents and timestamp + final timestamp = DateTime.now().millisecondsSinceEpoch; + final hashInput = jsonEncode(filter) + timestamp.toString(); + return 'sub_${hashInput.hashCode.abs()}'; } -} +} \ No newline at end of file diff --git a/lib/notifications/notification_service.dart b/lib/notifications/notification_service.dart index 030bac6e..9c095104 100644 --- a/lib/notifications/notification_service.dart +++ b/lib/notifications/notification_service.dart @@ -18,6 +18,7 @@ Future initializeNotifications() async { android: android, iOS: ios, linux: linux, + macOS: ios ); final plugin = FlutterLocalNotificationsPlugin(); await plugin.initialize(initSettings); diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b6..4b81f9b2 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b6..5caa9d15 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 00000000..29c8eb32 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 206e9899..c5bc05ea 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 4E01751B6286E29D961D56F7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 442022593F44B487B7E0B505 /* Pods_Runner.framework */; }; + 551447E97D99C7A2C18B92A5 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 95B1FDB795AD5DAF83CCA2A0 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,14 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 1D476F71501BA37E89973936 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 21952D613B510B4E199DA9B1 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 25A77DE7EB5393707DACD088 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* mostro_mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "mostro_mobile.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* Mostro P2P.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mostro P2P.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +81,13 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 442022593F44B487B7E0B505 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 48C70AF5E50E9F2A680D5F9C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 7407E578B4E4227E6E0D791B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 95B1FDB795AD5DAF83CCA2A0 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + CFD8F3A93E2F4D5FCCA3A41C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 551447E97D99C7A2C18B92A5 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4E01751B6286E29D961D56F7 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,13 +137,14 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + B1527F813E7C064EC3243A6F /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* mostro_mobile.app */, + 33CC10ED2044A3C60003C045 /* Mostro P2P.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -172,9 +185,24 @@ path = Runner; sourceTree = ""; }; + B1527F813E7C064EC3243A6F /* Pods */ = { + isa = PBXGroup; + children = ( + 7407E578B4E4227E6E0D791B /* Pods-Runner.debug.xcconfig */, + CFD8F3A93E2F4D5FCCA3A41C /* Pods-Runner.release.xcconfig */, + 1D476F71501BA37E89973936 /* Pods-Runner.profile.xcconfig */, + 21952D613B510B4E199DA9B1 /* Pods-RunnerTests.debug.xcconfig */, + 25A77DE7EB5393707DACD088 /* Pods-RunnerTests.release.xcconfig */, + 48C70AF5E50E9F2A680D5F9C /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 442022593F44B487B7E0B505 /* Pods_Runner.framework */, + 95B1FDB795AD5DAF83CCA2A0 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +214,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 1AEF54D95208AD587A1182D5 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +233,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + DC918E7C8C0DE61585524B37 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + F51200762EC816BECF6382A7 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -217,7 +248,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* mostro_mobile.app */; + productReference = 33CC10ED2044A3C60003C045 /* Mostro P2P.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -291,6 +322,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 1AEF54D95208AD587A1182D5 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -311,6 +364,7 @@ }; 33CC111E2044C6BF0003C045 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -327,7 +381,46 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire\n"; + }; + DC918E7C8C0DE61585524B37 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F51200762EC816BECF6382A7 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -380,10 +473,12 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 21952D613B510B4E199DA9B1 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.mostroMobile.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -394,10 +489,12 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 25A77DE7EB5393707DACD088 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.mostroMobile.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -408,10 +505,12 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 48C70AF5E50E9F2A680D5F9C /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.mostroMobile.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -476,13 +575,16 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 11.0; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -492,6 +594,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Manual; + MACOSX_DEPLOYMENT_TARGET = 11.0; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Profile; @@ -608,13 +711,16 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 11.0; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -628,13 +734,16 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 11.0; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -644,6 +753,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Manual; + MACOSX_DEPLOYMENT_TARGET = 11.0; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -652,6 +762,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + MACOSX_DEPLOYMENT_TARGET = 11.0; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 662c6707..c2f0ba2b 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -59,13 +59,14 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> @@ -82,7 +83,7 @@ diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16..21a3cc14 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 8e02df28..b3c17614 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -6,4 +6,8 @@ class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } From d5501736cdc55e01ed601fea2e61834ab122485e Mon Sep 17 00:00:00 2001 From: Biz Date: Fri, 18 Apr 2025 12:09:28 +1000 Subject: [PATCH 093/149] synchronize background services --- .../abstract_background_service.dart | 3 +- lib/background/background.dart | 67 +++++-------- .../desktop_background_service.dart | 99 ++++++++++++------- lib/background/mobile_background_service.dart | 99 ++++++++----------- lib/data/models/nostr_filter.dart | 55 +++++++++++ lib/data/repositories/base_storage.dart | 1 - .../order/notfiers/add_order_notifier.dart | 33 ++++--- .../order/screens/add_order_screen.dart | 4 +- lib/main.dart | 1 - .../foreground_service_controller.dart | 2 +- lib/notifications/notification_service.dart | 3 +- lib/services/mostro_service.dart | 11 ++- lib/shared/notifiers/session_notifier.dart | 13 ++- lib/shared/providers/app_init_provider.dart | 6 ++ 14 files changed, 234 insertions(+), 163 deletions(-) create mode 100644 lib/data/models/nostr_filter.dart rename lib/{features => }/notifications/foreground_service_controller.dart (90%) diff --git a/lib/background/abstract_background_service.dart b/lib/background/abstract_background_service.dart index 65855e72..7ed97c02 100644 --- a/lib/background/abstract_background_service.dart +++ b/lib/background/abstract_background_service.dart @@ -4,7 +4,8 @@ abstract class BackgroundService { Future initialize(Settings settings); Future subscribe(Map filter); Future unsubscribe(String subscriptionId); + void updateSettings(Settings settings); Future unsubscribeAll(); Future getActiveSubscriptionCount(); void setForegroundStatus(bool isForeground); -} \ No newline at end of file +} diff --git a/lib/background/background.dart b/lib/background/background.dart index 5cb0b084..78a5ee9d 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:ui'; -import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter/material.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/data/models/nostr_filter.dart'; import 'package:mostro_mobile/data/repositories/event_storage.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/notifications/notification_service.dart'; @@ -15,36 +15,6 @@ bool isAppForeground = false; @pragma('vm:entry-point') Future serviceMain(ServiceInstance service) async { - // Map to track active subscriptions - final Map> activeSubscriptions = {}; - - // Initialize service - service.on('settings-change').listen((event) { - // Initialize with settings - }); - - // Handle subscription creation - service.on('create-subscription').listen((event) { - if (event == null) return; - - final filter = event['filter'] as Map?; - final id = event['id'] as String?; - - if (filter != null && id != null) { - activeSubscriptions[id] = filter; - } - }); - - // Handle subscription cancellation - service.on('cancel-subscription').listen((event) { - if (event == null) return; - - final id = event['id'] as String?; - if (id != null && activeSubscriptions.containsKey(id)) { - activeSubscriptions.remove(id); - } - }); - // If on Android, set up a permanent notification so the OS won't kill it. if (service is AndroidServiceInstance) { service.setAsForegroundService(); @@ -69,6 +39,7 @@ Future serviceMain(ServiceInstance service) async { ); } + final Map> activeSubscriptions = {}; final nostrService = NostrService(); final db = await openMostroDatabase(); final backgroundStorage = EventStorage(db: db); @@ -77,26 +48,30 @@ Future serviceMain(ServiceInstance service) async { isAppForeground = data?['isForeground'] ?? false; }); - service.on('settings-change').listen((data) async { - await nostrService.init( - Settings.fromJson(data!['settings']), + service.on('settings-change').listen((data) { + if (data == null) return; + + final settingsMap = data['settings']; + if (settingsMap == null) return; + + nostrService.updateSettings( + Settings.fromJson( + settingsMap, + ), ); }); - // Listen for commands from the main isolate service.on('create-subscription').listen((data) { - final pList = data!['filter']['#p']; - List? p = pList != null ? [pList[0]] : null; + if (data == null || data['filter'] == null) return; - final filter = NostrFilter( - kinds: data['filter']['kinds'], - p: p, + final filter = NostrFilterX.fromJsonSafe( + data['filter'], ); final subscription = nostrService.subscribeToEvents(filter); subscription.listen((event) async { await backgroundStorage.putItem( - event.subscriptionId!, + event.id!, event, ); if (!isAppForeground) { @@ -104,6 +79,16 @@ Future serviceMain(ServiceInstance service) async { } }); }); + + // Handle subscription cancellation + service.on('cancel-subscription').listen((event) { + if (event == null) return; + + final id = event['id'] as String?; + if (id != null && activeSubscriptions.containsKey(id)) { + activeSubscriptions.remove(id); + } + }); } @pragma('vm:entry-point') diff --git a/lib/background/desktop_background_service.dart b/lib/background/desktop_background_service.dart index 70c609d7..d8cce5d7 100644 --- a/lib/background/desktop_background_service.dart +++ b/lib/background/desktop_background_service.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:isolate'; -import 'package:dart_nostr/dart_nostr.dart'; import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/models/nostr_filter.dart'; import 'package:mostro_mobile/data/repositories.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/notifications/notification_service.dart'; @@ -12,7 +12,6 @@ import 'abstract_background_service.dart'; class DesktopBackgroundService implements BackgroundService { // Similar implementation with subscription tracking final _subscriptions = >{}; - Isolate? _serviceIsolate; bool _isRunning = false; late SendPort _sendPort; @@ -21,11 +20,6 @@ class DesktopBackgroundService implements BackgroundService { final receivePort = ReceivePort(); await Isolate.spawn(_isolateEntry, receivePort.sendPort); _sendPort = await receivePort.first as SendPort; - - _sendPort.send({ - 'command': 'settings-change', - 'settings': settings.toJson(), - }); } static void _isolateEntry(SendPort mainSendPort) async { @@ -44,70 +38,103 @@ class DesktopBackgroundService implements BackgroundService { final command = message['command']; switch (command) { + case 'app-foreground-status': + isAppForeground = message['isForeground'] ?? false; + break; case 'settings-change': + if (message['settings'] == null) return; + await nostrService.updateSettings( - Settings.fromJson(message['settings']), + Settings.fromJson( + message['settings'], + ), ); - case 'subscribe': - final pList = message['filter']['#p']; - List? p = pList != null ? [pList[0]] : null; + break; + case 'create-subscription': + if (message['filter'] == null) return; - final filter = NostrFilter( - kinds: message['filter']['kinds'], - p: p, + final filter = NostrFilterX.fromJsonSafe( + message['filter'], ); final subscription = nostrService.subscribeToEvents(filter); - subscription.listen((event) async { await backgroundStorage.putItem( - event.subscriptionId!, + event.id!, event, ); if (!isAppForeground) { - await showLocalNotification(event); + //await showLocalNotification(event); } }); - case 'app-foreground-status': - isAppForeground = message['isForeground']; + break; default: logger.i('Unknown command: $command'); + break; } }); } @override Future subscribe(Map filter) async { - _sendPort.send({ - 'command': 'subscribe', - 'filter': filter, - }); + _sendPort.send( + { + 'command': 'create-subscription', + 'filter': filter, + }, + ); return true; } @override void setForegroundStatus(bool isForeground) { - _sendPort.send({ - 'command': 'app-foreground-status', - 'isForeground': isForeground, - }); + _sendPort.send( + { + 'command': 'app-foreground-status', + 'isForeground': isForeground, + }, + ); + } + + @override + Future getActiveSubscriptionCount() async { + return _subscriptions.length; } @override - Future getActiveSubscriptionCount() { - // TODO: implement getActiveSubscriptionCount - throw UnimplementedError(); + Future unsubscribe(String subscriptionId) async { + if (!_subscriptions.containsKey(subscriptionId)) { + return false; + } + + _subscriptions.remove(subscriptionId); + _sendPort.send( + { + 'command': 'cancel-subscription', + 'id': subscriptionId, + }, + ); + // If no more subscriptions, stop the service + if (_subscriptions.isEmpty && _isRunning) { + //await _stopService(); + } + return true; } @override - Future unsubscribe(String subscriptionId) { - // TODO: implement unsubscribe - throw UnimplementedError(); + Future unsubscribeAll() async { + for (final id in _subscriptions.keys.toList()) { + await unsubscribe(id); + } } @override - Future unsubscribeAll() { - // TODO: implement unsubscribeAll - throw UnimplementedError(); + void updateSettings(Settings settings) { + _sendPort.send( + { + 'command': 'settings-change', + 'settings': settings.toJson(), + }, + ); } } diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index 0a80412c..bbbfbaf2 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:mostro_mobile/background/background.dart'; @@ -7,7 +6,6 @@ import 'package:mostro_mobile/features/settings/settings.dart'; import 'abstract_background_service.dart'; class MobileBackgroundService implements BackgroundService { - final _eventsController = StreamController>.broadcast(); final service = FlutterBackgroundService(); final _subscriptions = >{}; bool _isRunning = false; @@ -15,116 +13,105 @@ class MobileBackgroundService implements BackgroundService { @override Future initialize(Settings settings) async { await service.configure( - // Keep existing configurations iosConfiguration: IosConfiguration( - autoStart: false, // Start manually only when needed + autoStart: true, onForeground: serviceMain, onBackground: onIosBackground, ), androidConfiguration: AndroidConfiguration( - autoStart: false, // Start manually only when needed + autoStart: true, onStart: serviceMain, isForegroundMode: false, - autoStartOnBoot: false, // Let our subscription logic control this + autoStartOnBoot: true, ), ); - - service.on('nostr-event').listen((data) { - _eventsController.add(data!); - }); - - // Initialize with settings but don't start - service.invoke( - 'settings-change', - settings.toJson(), - ); } @override Future subscribe(Map filter) async { - final subscriptionId = _generateSubscriptionId(filter); - _subscriptions[subscriptionId] = filter; - - // Start service if this is the first subscription - if (_subscriptions.length == 1 && !_isRunning) { - await _startService(); - } - - // Add subscription to the service service.invoke( 'create-subscription', - {'filter': filter, 'id': subscriptionId}, + { + 'filter': filter, + }, ); - + return true; } - + @override Future unsubscribe(String subscriptionId) async { if (!_subscriptions.containsKey(subscriptionId)) { return false; } - + _subscriptions.remove(subscriptionId); - service.invoke('cancel-subscription', {'id': subscriptionId}); - + service.invoke( + 'cancel-subscription', + { + 'id': subscriptionId, + }, + ); + // If no more subscriptions, stop the service if (_subscriptions.isEmpty && _isRunning) { await _stopService(); } - + return true; } - + @override Future unsubscribeAll() async { for (final id in _subscriptions.keys.toList()) { await unsubscribe(id); } } - + @override Future getActiveSubscriptionCount() async { return _subscriptions.length; } - + @override void setForegroundStatus(bool isForeground) { - service.invoke('app-foreground-status', { - 'isForeground': isForeground, - }); - - // When app goes to background but has subscriptions, - // ensure service keeps running - if (!isForeground && _subscriptions.isNotEmpty && !_isRunning) { - _startService(); - } + service.invoke( + 'app-foreground-status', + { + 'isForeground': isForeground, + }, + ); } - - // Helper methods + Future _startService() async { await service.startService(); _isRunning = true; - + // Re-register all active subscriptions for (final entry in _subscriptions.entries) { service.invoke( 'create-subscription', - {'filter': entry.value, 'id': entry.key}, + { + 'filter': entry.value, + 'id': entry.key, + }, ); } } - + Future _stopService() async { // Use invoke pattern to request the service to stop itself service.invoke('stopService'); _isRunning = false; } - - String _generateSubscriptionId(Map filter) { - // Generate a unique ID based on filter contents and timestamp - final timestamp = DateTime.now().millisecondsSinceEpoch; - final hashInput = jsonEncode(filter) + timestamp.toString(); - return 'sub_${hashInput.hashCode.abs()}'; + + @override + void updateSettings(Settings settings) { + service.invoke( + 'settings-change', + { + 'settings': settings.toJson(), + }, + ); } -} \ No newline at end of file +} diff --git a/lib/data/models/nostr_filter.dart b/lib/data/models/nostr_filter.dart new file mode 100644 index 00000000..16f98101 --- /dev/null +++ b/lib/data/models/nostr_filter.dart @@ -0,0 +1,55 @@ +import 'package:dart_nostr/dart_nostr.dart'; + +extension NostrFilterX on NostrFilter { + + static NostrFilter fromJsonSafe(Map json) { + final additional = {}; + + for (final entry in json.entries) { + if (![ + 'ids', + 'authors', + 'kinds', + '#e', + '#p', + '#t', + '#a', + 'since', + 'until', + 'limit', + 'search', + ].contains(entry.key)) { + additional[entry.key] = entry.value; + } + } + + return NostrFilter( + ids: castList(json['ids']), + authors: castList(json['authors']), + kinds: castList(json['kinds']), + e: castList(json['#e']), + p: castList(json['#p']), + t: castList(json['#t']), + a: castList(json['#a']), + since: safeCast(json['since']) != null + ? DateTime.fromMillisecondsSinceEpoch(safeCast(json['since'])! * 1000) + : null, + until: safeCast(json['until']) != null + ? DateTime.fromMillisecondsSinceEpoch(safeCast(json['until'])! * 1000) + : null, + limit: safeCast(json['limit']), + search: safeCast(json['search']), + additionalFilters: additional.isEmpty ? null : additional, + ); + } + + static T? safeCast(dynamic value) { + if (value is T) return value; + return null; + } + + static List? castList(dynamic value) { + if (value is List) return value.cast(); + return null; + } +} diff --git a/lib/data/repositories/base_storage.dart b/lib/data/repositories/base_storage.dart index 8aec6006..559e892c 100644 --- a/lib/data/repositories/base_storage.dart +++ b/lib/data/repositories/base_storage.dart @@ -98,7 +98,6 @@ abstract class BaseStorage { } Stream> watch() { - final store = stringMapStoreFactory.store('events'); return store.query().onSnapshots(db).map((snapshot) => snapshot .map( (record) => fromDbMap(record.key, record.value), diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart index b4a80074..82305769 100644 --- a/lib/features/order/notfiers/add_order_notifier.dart +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -20,22 +20,25 @@ class AddOrderNotifier extends AbstractMostroNotifier { @override void subscribe() { - subscription = ref.listen(addOrderEventsProvider(requestId!), (_, next) { - next.when( - data: (msg) { - if (msg.payload is Order) { - state = msg; - if (msg.action == Action.newOrder) { - confirmOrder(msg); + subscription = ref.listen( + addOrderEventsProvider(requestId!), + (_, next) { + next.when( + data: (msg) { + if (msg.payload is Order) { + state = msg; + if (msg.action == Action.newOrder) { + confirmOrder(msg); + } + } else if (msg.payload is CantDo) { + _handleCantDo(msg); } - } else if (msg.payload is CantDo) { - _handleCantDo(msg); - } - }, - error: (error, stack) => handleError(error, stack), - loading: () {}, - ); - }); + }, + error: (error, stack) => handleError(error, stack), + loading: () {}, + ); + }, + ); } void _handleCantDo(MostroMessage message) { diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 38571f42..eefbf5c5 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -470,7 +470,9 @@ class _AddOrderScreenState extends ConsumerState { // Generate a unique temporary ID for this new order final uuid = Uuid(); final tempOrderId = uuid.v4(); - final notifier = ref.read(addOrderNotifierProvider(tempOrderId).notifier); + final notifier = ref.read( + addOrderNotifierProvider(tempOrderId).notifier, + ); final fiatAmount = _maxFiatAmount != null ? 0 : _minFiatAmount; final minAmount = _maxFiatAmount != null ? _minFiatAmount : null; diff --git a/lib/main.dart b/lib/main.dart index 5039fbbb..fd487eb8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -42,4 +42,3 @@ Future main() async { ), ); } - diff --git a/lib/features/notifications/foreground_service_controller.dart b/lib/notifications/foreground_service_controller.dart similarity index 90% rename from lib/features/notifications/foreground_service_controller.dart rename to lib/notifications/foreground_service_controller.dart index 8827163b..aa21c587 100644 --- a/lib/features/notifications/foreground_service_controller.dart +++ b/lib/notifications/foreground_service_controller.dart @@ -2,7 +2,7 @@ import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; class ForegroundServiceController { - static const _channel = MethodChannel('com.example.myapp/foreground_service'); + static const _channel = MethodChannel('org.mostro.mobile/foreground_service'); static final _logger = Logger(); static Future startService() async { diff --git a/lib/notifications/notification_service.dart b/lib/notifications/notification_service.dart index 9c095104..0a8b7c9c 100644 --- a/lib/notifications/notification_service.dart +++ b/lib/notifications/notification_service.dart @@ -50,8 +50,7 @@ Future showLocalNotification(NostrEvent event) async { ), ); await notificationsPlugin.show( - event.createdAt?.millisecondsSinceEpoch ?? - DateTime.now().millisecondsSinceEpoch, + 0, 'New Mostro Event', 'Action: ${event.kind}', details, diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 86b9b94b..c20e3c5c 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/background/abstract_background_service.dart'; import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/data/repositories.dart'; @@ -18,6 +19,7 @@ class MostroService { final MostroStorage _messageStorage; final EventBus _bus; + final _logger = Logger(); Settings _settings; final BackgroundService backgroundService; @@ -38,10 +40,13 @@ class MostroService { } void _handleIncomingEvent(NostrEvent event) async { - - final currentSession = getSessionByOrderId(event.subscriptionId!); + final currentSession = await _sessionNotifier.getSessionByTradeKey( + event.tags!.firstWhere((t) => t[0] == 'p')[1], + ); if (currentSession == null) return; + _logger.i(event); + // Process event as you currently do: final decryptedEvent = await event.unWrap(currentSession.tradeKey.private); if (decryptedEvent.content == null) return; @@ -185,7 +190,7 @@ class MostroService { keyIndex: session.fullPrivacy ? null : session.keyIndex, ); - ref.read(nostrServiceProvider).publishEvent(event); + await ref.read(nostrServiceProvider).publishEvent(event); return session; } diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index 575d2e34..8f21b4d1 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -83,11 +83,14 @@ class SessionNotifier extends StateNotifier> { } } - Future getSessionByTradeKey(String tradeKey) async { - final sessions = await _storage.getAllSessions(); - return sessions.firstWhere( - (s) => s.tradeKey.public == tradeKey, - ); + Session? getSessionByTradeKey(String tradeKey) { + try { + return _sessions.values.firstWhere( + (s) => s.tradeKey.public == tradeKey, + ); + } on StateError { + return null; + } } Future loadSession(int keyIndex) async { diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index dca308b1..4d4cf557 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -8,6 +8,7 @@ import 'package:mostro_mobile/features/order/providers/order_notifier_provider.d import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; +import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; @@ -25,10 +26,15 @@ final appInitializerProvider = FutureProvider((ref) async { await sessionManager.init(); final mostroService = ref.read(mostroServiceProvider); + mostroService.init(); + + final backgroundService = ref.read(backgroundServiceProvider); + backgroundService.updateSettings(ref.read(settingsProvider)); ref.listen(settingsProvider, (previous, next) { sessionManager.updateSettings(next); mostroService.updateSettings(next); + backgroundService.updateSettings(next); }); final mostroStorage = ref.read(mostroStorageProvider); From 414af2b923b9d06d9be7bc11fc08d888609937c8 Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 19 Apr 2025 17:23:58 +1000 Subject: [PATCH 094/149] Now uses two separate databases --- .../abstract_background_service.dart | 2 + lib/background/background.dart | 15 +++-- .../desktop_background_service.dart | 58 ++++++++++++++++--- lib/background/mobile_background_service.dart | 15 ++++- lib/core/config.dart | 7 ++- lib/data/repositories/mostro_storage.dart | 16 ++--- .../order/notfiers/add_order_notifier.dart | 5 +- .../providers/order_notifier_provider.dart | 19 ++++-- .../widgets/mostro_message_detail_widget.dart | 15 +++-- lib/main.dart | 7 ++- lib/services/mostro_service.dart | 8 +-- lib/shared/notifiers/session_notifier.dart | 2 +- lib/shared/providers/app_init_provider.dart | 2 +- .../providers/mostro_database_provider.dart | 9 +-- pubspec.lock | 46 +++++++-------- pubspec.yaml | 3 +- 16 files changed, 158 insertions(+), 71 deletions(-) diff --git a/lib/background/abstract_background_service.dart b/lib/background/abstract_background_service.dart index 7ed97c02..22a049e4 100644 --- a/lib/background/abstract_background_service.dart +++ b/lib/background/abstract_background_service.dart @@ -1,3 +1,4 @@ +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; abstract class BackgroundService { @@ -8,4 +9,5 @@ abstract class BackgroundService { Future unsubscribeAll(); Future getActiveSubscriptionCount(); void setForegroundStatus(bool isForeground); + Stream get eventsStream; } diff --git a/lib/background/background.dart b/lib/background/background.dart index 78a5ee9d..b1e28676 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -10,11 +10,17 @@ import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/notifications/notification_service.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; bool isAppForeground = false; @pragma('vm:entry-point') Future serviceMain(ServiceInstance service) async { + + final dir = await getApplicationSupportDirectory(); + final path = p.join(dir.path, 'mostro', 'databases', 'background.db'); + // If on Android, set up a permanent notification so the OS won't kill it. if (service is AndroidServiceInstance) { service.setAsForegroundService(); @@ -41,11 +47,12 @@ Future serviceMain(ServiceInstance service) async { final Map> activeSubscriptions = {}; final nostrService = NostrService(); - final db = await openMostroDatabase(); + + final db = await openMostroDatabase(path); final backgroundStorage = EventStorage(db: db); service.on('app-foreground-status').listen((data) { - isAppForeground = data?['isForeground'] ?? false; + isAppForeground = data?['is-foreground'] ?? isAppForeground; }); service.on('settings-change').listen((data) { @@ -74,13 +81,13 @@ Future serviceMain(ServiceInstance service) async { event.id!, event, ); + service.invoke('event', event.toMap()); if (!isAppForeground) { - await showLocalNotification(event); + //await showLocalNotification(event); } }); }); - // Handle subscription cancellation service.on('cancel-subscription').listen((event) { if (event == null) return; diff --git a/lib/background/desktop_background_service.dart b/lib/background/desktop_background_service.dart index d8cce5d7..870f3341 100644 --- a/lib/background/desktop_background_service.dart +++ b/lib/background/desktop_background_service.dart @@ -1,36 +1,63 @@ import 'dart:async'; import 'dart:isolate'; +import 'package:dart_nostr/nostr/model/event/event.dart'; +import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/data/models/nostr_filter.dart'; import 'package:mostro_mobile/data/repositories.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; -import 'package:mostro_mobile/notifications/notification_service.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'abstract_background_service.dart'; class DesktopBackgroundService implements BackgroundService { - // Similar implementation with subscription tracking + final _eventsController = StreamController.broadcast(); + final _subscriptions = >{}; bool _isRunning = false; late SendPort _sendPort; @override Future initialize(Settings settings) async { + final token = ServicesBinding.rootIsolateToken!; + final dir = await getApplicationSupportDirectory(); + final path = p.join(dir.path, 'mostro', 'databases', 'background.db'); + final receivePort = ReceivePort(); - await Isolate.spawn(_isolateEntry, receivePort.sendPort); - _sendPort = await receivePort.first as SendPort; + await Isolate.spawn(_isolateEntry, [receivePort.sendPort, token, path]); + + receivePort.listen((message) { + if (message is SendPort) { + _sendPort = message; + } + if (message is Map && message.containsKey('event')) { + final event = NostrEventExtensions.fromMap(message['event']); + _eventsController.add(event); + } + if (message is Map && message.containsKey('is-running')) { + _isRunning = message['is-running']; + } + }); } - static void _isolateEntry(SendPort mainSendPort) async { + static void _isolateEntry(List args) async { final isolateReceivePort = ReceivePort(); + final mainSendPort = args[0] as SendPort; + final token = args[1] as RootIsolateToken; + final dbPath = args[2] as String; + mainSendPort.send(isolateReceivePort.sendPort); + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + final nostrService = NostrService(); - final db = await openMostroDatabase(); + final db = await openMostroDatabase(dbPath); final backgroundStorage = EventStorage(db: db); final logger = Logger(); - bool isAppForeground = false; + bool isAppForeground = true; isolateReceivePort.listen((message) async { if (message is! Map || message['command'] == null) return; @@ -39,7 +66,7 @@ class DesktopBackgroundService implements BackgroundService { switch (command) { case 'app-foreground-status': - isAppForeground = message['isForeground'] ?? false; + isAppForeground = message['is-foreground'] ?? isAppForeground; break; case 'settings-change': if (message['settings'] == null) return; @@ -63,6 +90,9 @@ class DesktopBackgroundService implements BackgroundService { event.id!, event, ); + mainSendPort.send({ + 'event': event.toMap(), + }); if (!isAppForeground) { //await showLocalNotification(event); } @@ -73,6 +103,10 @@ class DesktopBackgroundService implements BackgroundService { break; } }); + + mainSendPort.send({ + 'is-running': true, + }); } @override @@ -88,10 +122,11 @@ class DesktopBackgroundService implements BackgroundService { @override void setForegroundStatus(bool isForeground) { + if (!_isRunning) return; _sendPort.send( { 'command': 'app-foreground-status', - 'isForeground': isForeground, + 'is-foreground': isForeground, }, ); } @@ -123,6 +158,7 @@ class DesktopBackgroundService implements BackgroundService { @override Future unsubscribeAll() async { + if (!_isRunning) return; for (final id in _subscriptions.keys.toList()) { await unsubscribe(id); } @@ -130,6 +166,7 @@ class DesktopBackgroundService implements BackgroundService { @override void updateSettings(Settings settings) { + if (!_isRunning) return; _sendPort.send( { 'command': 'settings-change', @@ -137,4 +174,7 @@ class DesktopBackgroundService implements BackgroundService { }, ); } + + @override + Stream get eventsStream => _eventsController.stream; } diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index bbbfbaf2..ae9f4a10 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -1,12 +1,16 @@ import 'dart:async'; +import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:mostro_mobile/background/background.dart'; +import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'abstract_background_service.dart'; class MobileBackgroundService implements BackgroundService { final service = FlutterBackgroundService(); + final _eventsController = StreamController.broadcast(); + final _subscriptions = >{}; bool _isRunning = false; @@ -25,6 +29,12 @@ class MobileBackgroundService implements BackgroundService { autoStartOnBoot: true, ), ); + + service.on('event').listen((data) { + _eventsController.add( + NostrEventExtensions.fromMap(data!), + ); + }); } @override @@ -78,7 +88,7 @@ class MobileBackgroundService implements BackgroundService { service.invoke( 'app-foreground-status', { - 'isForeground': isForeground, + 'is-foreground': isForeground, }, ); } @@ -114,4 +124,7 @@ class MobileBackgroundService implements BackgroundService { }, ); } + + @override + Stream get eventsStream => _eventsController.stream; } diff --git a/lib/core/config.dart b/lib/core/config.dart index 3333f4f0..0e0cc0c4 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -3,7 +3,8 @@ import 'package:flutter/foundation.dart'; class Config { // Configuración de Nostr static const List nostrRelays = [ - 'wss://relay.mostro.network', + //'wss://relay.mostro.network', + 'ws://10.0.0.169:7000', //'ws://127.0.0.1:7000', //'ws://192.168.1.103:7000', //'ws://10.0.2.2:7000', // mobile emulator @@ -11,8 +12,8 @@ class Config { // hexkey de Mostro static const String mostroPubKey = - '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; - //'9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; + //'82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + '9d9d0455a96871f2dc4289b8312429db2e925f167b37c77bf7b28014be235980'; static const String dBName = 'mostro.db'; static const String dBPassword = 'mostro'; diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 4c552de5..3848b3d9 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -16,10 +16,11 @@ class MostroStorage extends BaseStorage { try { await putItem(id, message); _logger.i( - 'Saved message of type \${message.payload.runtimeType} with id \$id'); + 'Saved message of type ${message.payload.runtimeType} with id $id', + ); } catch (e, stack) { _logger.e( - 'addMessage failed for \$id', + 'addMessage failed for $id', error: e, stackTrace: stack, ); @@ -43,8 +44,7 @@ class MostroStorage extends BaseStorage { try { return await getItem(id); } catch (e, stack) { - _logger.e('Error deserializing message \$id', - error: e, stackTrace: stack); + _logger.e('Error deserializing message $id', error: e, stackTrace: stack); return null; } } @@ -64,9 +64,9 @@ class MostroStorage extends BaseStorage { final id = '${T.runtimeType}:$orderId'; try { await deleteItem(id); - _logger.i('Message \$id deleted from DB'); + _logger.i('Message $id deleted from DB'); } catch (e, stack) { - _logger.e('deleteMessage failed for \$id', error: e, stackTrace: stack); + _logger.e('deleteMessage failed for $id', error: e, stackTrace: stack); rethrow; } } @@ -90,9 +90,9 @@ class MostroStorage extends BaseStorage { final id = messageKey(m); try { await deleteItem(id); - _logger.i('Message \$id deleted from DB'); + _logger.i('Message $id deleted from DB'); } catch (e, stack) { - _logger.e('deleteMessage failed for \$id', + _logger.e('deleteMessage failed for $id', error: e, stackTrace: stack); rethrow; } diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart index 82305769..859c3063 100644 --- a/lib/features/order/notfiers/add_order_notifier.dart +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -54,8 +54,9 @@ class AddOrderNotifier extends AbstractMostroNotifier { // This method is called when the order is confirmed. Future confirmOrder(MostroMessage confirmedOrder) async { - final orderNotifier = - ref.watch(orderNotifierProvider(confirmedOrder.id!).notifier); + final orderNotifier = ref.watch( + orderNotifierProvider(confirmedOrder.id!).notifier, + ); handleOrderUpdate(); orderNotifier.subscribe(); dispose(); diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index 91dc5282..ca10084d 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -36,21 +36,30 @@ final addOrderNotifierProvider = final cantDoNotifierProvider = StateNotifierProvider.family( (ref, orderId) { - return CantDoNotifier(orderId, ref); + return CantDoNotifier( + orderId, + ref, + ); }, ); final paymentNotifierProvider = StateNotifierProvider.family( (ref, orderId) { - return PaymentRequestNotifier(orderId, ref); + return PaymentRequestNotifier( + orderId, + ref, + ); }, ); final disputeNotifierProvider = StateNotifierProvider.family( (ref, orderId) { - return DisputeNotifier(orderId, ref); + return DisputeNotifier( + orderId, + ref, + ); }, ); @@ -66,6 +75,8 @@ class OrderTypeNotifier extends _$OrderTypeNotifier { final addOrderEventsProvider = StreamProvider.family( (ref, requestId) { final bus = ref.watch(eventBusProvider); - return bus.stream.where((msg) => msg.requestId == requestId); + return bus.stream.where( + (msg) => msg.requestId == requestId, + ); }, ); diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index d2fdefab..111e6de1 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -127,14 +127,17 @@ class MostroMessageDetail extends ConsumerWidget { actionText = S.of(context)!.cooperativeCancelAccepted(order.orderId!); break; case actions.Action.disputeInitiatedByYou: - final payload = ref.read(disputeNotifierProvider(order.orderId!)).getPayload(); + final payload = ref + .read(disputeNotifierProvider(order.orderId!)) + .getPayload(); actionText = S .of(context)! .disputeInitiatedByYou(order.orderId!, payload!.disputeId); break; case actions.Action.disputeInitiatedByPeer: - - final payload = ref.read(disputeNotifierProvider(order.orderId!)).getPayload(); + final payload = ref + .read(disputeNotifierProvider(order.orderId!)) + .getPayload(); actionText = S .of(context)! .disputeInitiatedByPeer(order.orderId!, payload!.disputeId); @@ -163,7 +166,11 @@ class MostroMessageDetail extends ConsumerWidget { case actions.Action.cantDo: final msg = ref.read(cantDoNotifierProvider(order.orderId!)); final cantDo = msg.getPayload(); - switch (cantDo!.cantDoReason) { + if (cantDo == null) { + actionText = "Can't Do Message Not Found"; + break; + } + switch (cantDo.cantDoReason) { case CantDoReason.invalidSignature: actionText = S.of(context)!.invalidSignature; break; diff --git a/lib/main.dart b/lib/main.dart index fd487eb8..8709df7d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,8 @@ import 'package:mostro_mobile/notifications/notification_service.dart'; import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; import 'package:mostro_mobile/shared/providers/providers.dart'; import 'package:mostro_mobile/shared/utils/biometrics_helper.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; Future main() async { @@ -18,7 +20,10 @@ Future main() async { final biometricsHelper = BiometricsHelper(); final sharedPreferences = SharedPreferencesAsync(); final secureStorage = const FlutterSecureStorage(); - final database = await openMostroDatabase(); + + final dir = await getApplicationSupportDirectory(); + final path = p.join(dir.path, 'mostro', 'databases', 'mostro.db'); + final database = await openMostroDatabase(path); final settings = SettingsNotifier(sharedPreferences); await settings.init(); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index c20e3c5c..385823cf 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -34,19 +34,17 @@ class MostroService { ) : _settings = ref.read(settingsProvider); Future init() async { - _eventStorage.watch().listen((data) { - data.forEach(_handleIncomingEvent); + backgroundService.eventsStream.listen((data) { + _handleIncomingEvent(data); }); } void _handleIncomingEvent(NostrEvent event) async { - final currentSession = await _sessionNotifier.getSessionByTradeKey( + final currentSession = _sessionNotifier.getSessionByTradeKey( event.tags!.firstWhere((t) => t[0] == 'p')[1], ); if (currentSession == null) return; - _logger.i(event); - // Process event as you currently do: final decryptedEvent = await event.unWrap(currentSession.tradeKey.private); if (decryptedEvent.content == null) return; diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index 8f21b4d1..541851cc 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -85,7 +85,7 @@ class SessionNotifier extends StateNotifier> { Session? getSessionByTradeKey(String tradeKey) { try { - return _sessions.values.firstWhere( + return state.firstWhere( (s) => s.tradeKey.public == tradeKey, ); } on StateError { diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 4d4cf557..174b499c 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -26,7 +26,7 @@ final appInitializerProvider = FutureProvider((ref) async { await sessionManager.init(); final mostroService = ref.read(mostroServiceProvider); - mostroService.init(); + await mostroService.init(); final backgroundService = ref.read(backgroundServiceProvider); backgroundService.updateSettings(ref.read(settingsProvider)); diff --git a/lib/shared/providers/mostro_database_provider.dart b/lib/shared/providers/mostro_database_provider.dart index 694826fc..e2e4842d 100644 --- a/lib/shared/providers/mostro_database_provider.dart +++ b/lib/shared/providers/mostro_database_provider.dart @@ -1,9 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:tekartik_app_flutter_sembast/sembast.dart'; +import 'package:sembast/sembast_io.dart'; -Future openMostroDatabase() async { - var factory = getDatabaseFactory(); - final db = await factory.openDatabase('mostro.db'); +Future openMostroDatabase(String dbName) async { + //var factory = getDatabaseFactory(packageName: dbName); + //final db = await factory.openDatabase(dbName); + final db = await databaseFactoryIo.openDatabase(dbName); return db; } diff --git a/pubspec.lock b/pubspec.lock index d9ca5d7c..3106eb76 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -34,10 +34,10 @@ packages: dependency: transitive description: name: archive - sha256: "7dcbd0f87fe5f61cb28da39a1a8b70dbc106e2fe0516f7836eb7bb2948481a12" + sha256: a7f37ff061d7abc2fcf213554b9dcaca713c5853afa5c065c44888bc9ccaf813 url: "https://pub.dev" source: hosted - version: "4.0.5" + version: "4.0.6" args: dependency: transitive description: @@ -162,10 +162,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" + sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" url: "https://pub.dev" source: hosted - version: "2.4.15" + version: "2.4.14" build_runner_core: dependency: transitive description: @@ -625,10 +625,10 @@ packages: dependency: transitive description: name: flutter_svg - sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b + sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 url: "https://pub.dev" source: hosted - version: "2.0.17" + version: "2.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -672,10 +672,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + sha256: "4cdfcc6a178632d1dbb7a728f8e84a1466211354704b9cdc03eee661d3277732" url: "https://pub.dev" source: hosted - version: "14.8.1" + version: "15.0.0" google_fonts: dependency: "direct main" description: @@ -987,7 +987,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -1078,10 +1078,10 @@ packages: dependency: transitive description: name: posix - sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.0.2" process: dependency: transitive description: @@ -1270,10 +1270,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "2.0.1" sky_engine: dependency: transitive description: flutter @@ -1347,10 +1347,10 @@ packages: dependency: transitive description: name: sqflite_common_ffi_web - sha256: "983cf7b33b16e6bc086c8e09f6a1fae69d34cdb167d7acaf64cbd3515942d4e6" + sha256: cfc9d1c61a3e06e5b2e96994a44b11125b4f451fee95b9fad8bd473b4613d592 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "0.4.3+1" sqflite_darwin: dependency: transitive description: @@ -1371,10 +1371,10 @@ packages: dependency: transitive description: name: sqlite3 - sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e" + sha256: fde692580bee3379374af1f624eb3e113ab2865ecb161dbe2d8ac2de9735dbdb url: "https://pub.dev" source: hosted - version: "2.7.5" + version: "2.4.5" stack_trace: dependency: transitive description: @@ -1444,7 +1444,7 @@ packages: description: path: app_sembast ref: dart3a - resolved-ref: e3fe710a4b9e8b6438c99da01bc430b9fcea4eeb + resolved-ref: "329c102aec5086bbca15665f86267784405b5d81" url: "https://github.com/tekartik/app_flutter_utils.dart" source: git version: "0.5.1" @@ -1453,7 +1453,7 @@ packages: description: path: app_sqflite ref: dart3a - resolved-ref: e3fe710a4b9e8b6438c99da01bc430b9fcea4eeb + resolved-ref: "329c102aec5086bbca15665f86267784405b5d81" url: "https://github.com/tekartik/app_flutter_utils.dart" source: git version: "0.6.1" @@ -1607,18 +1607,18 @@ packages: dependency: transitive description: name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "0.5.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.5" webdriver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 65118cb9..91269a99 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,7 +51,7 @@ dependencies: intl: ^0.19.0 uuid: ^3.0.7 flutter_secure_storage: ^10.0.0-beta.4 - go_router: ^14.6.2 + go_router: ^15.0.0 bip39: ^1.0.6 flutter_hooks: ^0.21.2 hooks_riverpod: ^2.6.1 @@ -82,6 +82,7 @@ dependencies: flutter_local_notifications: ^19.0.0 flutter_background_service: ^5.1.0 system_tray: ^2.0.3 + path_provider: ^2.1.5 dev_dependencies: flutter_test: From 8768fcb372992d6ea5eafdb10faa3990fb21c118 Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 19 Apr 2025 18:39:54 +1000 Subject: [PATCH 095/149] ensure NostrService init --- lib/background/background.dart | 2 +- lib/background/mobile_background_service.dart | 2 ++ lib/data/repositories/mostro_storage.dart | 6 ++++++ lib/services/mostro_service.dart | 10 +++++++--- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/background/background.dart b/lib/background/background.dart index b1e28676..e216190e 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -61,7 +61,7 @@ Future serviceMain(ServiceInstance service) async { final settingsMap = data['settings']; if (settingsMap == null) return; - nostrService.updateSettings( + nostrService.init( Settings.fromJson( settingsMap, ), diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index ae9f4a10..32402cf9 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -35,6 +35,8 @@ class MobileBackgroundService implements BackgroundService { NostrEventExtensions.fromMap(data!), ); }); + + service.startService(); } @override diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 3848b3d9..dfe26bf0 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -135,4 +135,10 @@ class MostroStorage extends BaseStorage { final id = msg.id ?? msg.requestId.toString(); return '$type:$id'; } + + Future hasMessage(MostroMessage msg) async { + return hasItem( + messageKey(msg), + ); + } } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 385823cf..c53afe70 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -34,12 +34,15 @@ class MostroService { ) : _settings = ref.read(settingsProvider); Future init() async { - backgroundService.eventsStream.listen((data) { - _handleIncomingEvent(data); + backgroundService.eventsStream.listen((data) async { + await _handleIncomingEvent(data); }); } - void _handleIncomingEvent(NostrEvent event) async { + Future _handleIncomingEvent(NostrEvent event) async { + if (await _eventStorage.hasItem(event.id!)) return; + await _eventStorage.putItem(event.id!, event); + final currentSession = _sessionNotifier.getSessionByTradeKey( event.tags!.firstWhere((t) => t[0] == 'p')[1], ); @@ -54,6 +57,7 @@ class MostroService { final msg = MostroMessage.fromJson(result[0]); if (msg.id != null) { + if (await _messageStorage.hasMessage(msg)) return; ref.read(orderActionNotifierProvider(msg.id!).notifier).set(msg.action); } if (msg.action == Action.canceled) { From fe47e243feba5c468d19d1074820e4b489254f60 Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 19 Apr 2025 19:33:52 +1000 Subject: [PATCH 096/149] Rebuild of out of date annotated classes --- .../order/notfiers/add_order_notifier.dart | 14 +- lib/services/event_bus.dart | 2 +- lib/services/mostro_service.dart | 9 +- .../providers/mostro_service_provider.g.dart | 2 +- test/mocks.mocks.dart | 393 ++++--- test/services/mostro_service_test.mocks.dart | 1037 ++++++++++------- 6 files changed, 871 insertions(+), 586 deletions(-) diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart index 859c3063..1aaf9805 100644 --- a/lib/features/order/notfiers/add_order_notifier.dart +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -16,6 +16,13 @@ class AddOrderNotifier extends AbstractMostroNotifier { AddOrderNotifier(super.orderId, super.ref) { mostroService = ref.read(mostroServiceProvider); + + requestId = BigInt.parse( + orderId.replaceAll('-', ''), + radix: 16, + ).toUnsigned(64).toInt(); + + subscribe(); } @override @@ -63,19 +70,12 @@ class AddOrderNotifier extends AbstractMostroNotifier { } Future submitOrder(Order order) async { - requestId = requestId ?? - BigInt.parse( - orderId.replaceAll('-', ''), - radix: 16, - ).toUnsigned(64).toInt(); - final message = MostroMessage( action: Action.newOrder, id: null, requestId: requestId, payload: order, ); - if (subscription == null) subscribe(); await mostroService.submitOrder(message); } } diff --git a/lib/services/event_bus.dart b/lib/services/event_bus.dart index 8dc6744d..5e709503 100644 --- a/lib/services/event_bus.dart +++ b/lib/services/event_bus.dart @@ -17,6 +17,6 @@ class EventBus { @riverpod EventBus eventBus(Ref ref) { final bus = EventBus(); - // ref.onDispose(bus.dispose); + //ref.onDispose(bus.dispose); return bus; } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index c53afe70..2cb97901 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -41,7 +41,10 @@ class MostroService { Future _handleIncomingEvent(NostrEvent event) async { if (await _eventStorage.hasItem(event.id!)) return; - await _eventStorage.putItem(event.id!, event); + await _eventStorage.putItem( + event.id!, + event, + ); final currentSession = _sessionNotifier.getSessionByTradeKey( event.tags!.firstWhere((t) => t[0] == 'p')[1], @@ -49,7 +52,9 @@ class MostroService { if (currentSession == null) return; // Process event as you currently do: - final decryptedEvent = await event.unWrap(currentSession.tradeKey.private); + final decryptedEvent = await event.unWrap( + currentSession.tradeKey.private, + ); if (decryptedEvent.content == null) return; final result = jsonDecode(decryptedEvent.content!); diff --git a/lib/shared/providers/mostro_service_provider.g.dart b/lib/shared/providers/mostro_service_provider.g.dart index 34196ff7..5b7af983 100644 --- a/lib/shared/providers/mostro_service_provider.g.dart +++ b/lib/shared/providers/mostro_service_provider.g.dart @@ -22,7 +22,7 @@ final eventStorageProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef EventStorageRef = AutoDisposeProviderRef; -String _$mostroServiceHash() => r'6e27d58dbe7170c180dcf3729f0f5f27a29f06c6'; +String _$mostroServiceHash() => r'26300c32176dcefaeeaae02ec6932998060972fc'; /// See also [mostroService]. @ProviderFor(mostroService) diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index de227f6f..ac0941b1 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -3,18 +3,18 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; +import 'dart:async' as _i6; -import 'package:dart_nostr/dart_nostr.dart' as _i10; +import 'package:dart_nostr/nostr/model/event/event.dart' as _i9; import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:mostro_mobile/data/models/mostro_message.dart' as _i6; -import 'package:mostro_mobile/data/models/payload.dart' as _i7; -import 'package:mostro_mobile/data/models/session.dart' as _i3; +import 'package:mostro_mobile/background/abstract_background_service.dart' + as _i3; +import 'package:mostro_mobile/data/models.dart' as _i4; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart' - as _i9; -import 'package:mostro_mobile/features/settings/settings.dart' as _i8; -import 'package:mostro_mobile/services/mostro_service.dart' as _i4; + as _i8; +import 'package:mostro_mobile/features/settings/settings.dart' as _i7; +import 'package:mostro_mobile/services/mostro_service.dart' as _i5; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -32,215 +32,316 @@ import 'package:mostro_mobile/services/mostro_service.dart' as _i4; class _FakeRef_0 extends _i1.SmartFake implements _i2.Ref { - _FakeRef_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeRef_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } -class _FakeSession_1 extends _i1.SmartFake implements _i3.Session { - _FakeSession_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); +class _FakeBackgroundService_1 extends _i1.SmartFake + implements _i3.BackgroundService { + _FakeBackgroundService_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeSession_2 extends _i1.SmartFake implements _i4.Session { + _FakeSession_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [MostroService]. /// /// See the documentation for Mockito's code generation for more information. -class MockMostroService extends _i1.Mock implements _i4.MostroService { +class MockMostroService extends _i1.Mock implements _i5.MostroService { MockMostroService() { _i1.throwOnMissingStub(this); } @override - _i2.Ref get ref => - (super.noSuchMethod( - Invocation.getter(#ref), - returnValue: _FakeRef_0(this, Invocation.getter(#ref)), - ) - as _i2.Ref); + _i2.Ref get ref => (super.noSuchMethod( + Invocation.getter(#ref), + returnValue: _FakeRef_0( + this, + Invocation.getter(#ref), + ), + ) as _i2.Ref); + + @override + _i3.BackgroundService get backgroundService => (super.noSuchMethod( + Invocation.getter(#backgroundService), + returnValue: _FakeBackgroundService_1( + this, + Invocation.getter(#backgroundService), + ), + ) as _i3.BackgroundService); + + @override + _i6.Future init() => (super.noSuchMethod( + Invocation.method( + #init, + [], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - void subscribe(_i3.Session? session) => super.noSuchMethod( - Invocation.method(#subscribe, [session]), - returnValueForMissingStub: null, - ); + void subscribe(_i4.Session? session) => super.noSuchMethod( + Invocation.method( + #subscribe, + [session], + ), + returnValueForMissingStub: null, + ); @override - _i3.Session? getSessionByOrderId(String? orderId) => - (super.noSuchMethod(Invocation.method(#getSessionByOrderId, [orderId])) - as _i3.Session?); + _i4.Session? getSessionByOrderId(String? orderId) => + (super.noSuchMethod(Invocation.method( + #getSessionByOrderId, + [orderId], + )) as _i4.Session?); @override - _i5.Future submitOrder(_i6.MostroMessage<_i7.Payload>? order) => + _i6.Future submitOrder(_i4.MostroMessage<_i4.Payload>? order) => (super.noSuchMethod( - Invocation.method(#submitOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method( + #submitOrder, + [order], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i5.Future takeBuyOrder(String? orderId, int? amount) => + _i6.Future takeBuyOrder( + String? orderId, + int? amount, + ) => (super.noSuchMethod( - Invocation.method(#takeBuyOrder, [orderId, amount]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method( + #takeBuyOrder, + [ + orderId, + amount, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i5.Future takeSellOrder( + _i6.Future takeSellOrder( String? orderId, int? amount, String? lnAddress, ) => (super.noSuchMethod( - Invocation.method(#takeSellOrder, [orderId, amount, lnAddress]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method( + #takeSellOrder, + [ + orderId, + amount, + lnAddress, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i5.Future sendInvoice(String? orderId, String? invoice, int? amount) => + _i6.Future sendInvoice( + String? orderId, + String? invoice, + int? amount, + ) => (super.noSuchMethod( - Invocation.method(#sendInvoice, [orderId, invoice, amount]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method( + #sendInvoice, + [ + orderId, + invoice, + amount, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i5.Future cancelOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#cancelOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i6.Future cancelOrder(String? orderId) => (super.noSuchMethod( + Invocation.method( + #cancelOrder, + [orderId], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i5.Future sendFiatSent(String? orderId) => - (super.noSuchMethod( - Invocation.method(#sendFiatSent, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i6.Future sendFiatSent(String? orderId) => (super.noSuchMethod( + Invocation.method( + #sendFiatSent, + [orderId], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i5.Future releaseOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#releaseOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i6.Future releaseOrder(String? orderId) => (super.noSuchMethod( + Invocation.method( + #releaseOrder, + [orderId], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i5.Future disputeOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#disputeOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i6.Future disputeOrder(String? orderId) => (super.noSuchMethod( + Invocation.method( + #disputeOrder, + [orderId], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i5.Future submitRating(String? orderId, int? rating) => + _i6.Future submitRating( + String? orderId, + int? rating, + ) => (super.noSuchMethod( - Invocation.method(#submitRating, [orderId, rating]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + Invocation.method( + #submitRating, + [ + orderId, + rating, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i5.Future<_i3.Session> publishOrder(_i6.MostroMessage<_i7.Payload>? order) => + _i6.Future<_i4.Session> publishOrder(_i4.MostroMessage<_i4.Payload>? order) => (super.noSuchMethod( - Invocation.method(#publishOrder, [order]), - returnValue: _i5.Future<_i3.Session>.value( - _FakeSession_1(this, Invocation.method(#publishOrder, [order])), - ), - ) - as _i5.Future<_i3.Session>); - - @override - void updateSettings(_i8.Settings? settings) => super.noSuchMethod( - Invocation.method(#updateSettings, [settings]), - returnValueForMissingStub: null, - ); + Invocation.method( + #publishOrder, + [order], + ), + returnValue: _i6.Future<_i4.Session>.value(_FakeSession_2( + this, + Invocation.method( + #publishOrder, + [order], + ), + )), + ) as _i6.Future<_i4.Session>); + + @override + void updateSettings(_i7.Settings? settings) => super.noSuchMethod( + Invocation.method( + #updateSettings, + [settings], + ), + returnValueForMissingStub: null, + ); } /// A class which mocks [OpenOrdersRepository]. /// /// See the documentation for Mockito's code generation for more information. class MockOpenOrdersRepository extends _i1.Mock - implements _i9.OpenOrdersRepository { + implements _i8.OpenOrdersRepository { MockOpenOrdersRepository() { _i1.throwOnMissingStub(this); } @override - _i5.Stream> get eventsStream => - (super.noSuchMethod( - Invocation.getter(#eventsStream), - returnValue: _i5.Stream>.empty(), - ) - as _i5.Stream>); + _i6.Stream> get eventsStream => (super.noSuchMethod( + Invocation.getter(#eventsStream), + returnValue: _i6.Stream>.empty(), + ) as _i6.Stream>); @override void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); @override - _i5.Future<_i10.NostrEvent?> getOrderById(String? orderId) => + _i6.Future<_i9.NostrEvent?> getOrderById(String? orderId) => (super.noSuchMethod( - Invocation.method(#getOrderById, [orderId]), - returnValue: _i5.Future<_i10.NostrEvent?>.value(), - ) - as _i5.Future<_i10.NostrEvent?>); + Invocation.method( + #getOrderById, + [orderId], + ), + returnValue: _i6.Future<_i9.NostrEvent?>.value(), + ) as _i6.Future<_i9.NostrEvent?>); @override - _i5.Future addOrder(_i10.NostrEvent? order) => - (super.noSuchMethod( - Invocation.method(#addOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i6.Future addOrder(_i9.NostrEvent? order) => (super.noSuchMethod( + Invocation.method( + #addOrder, + [order], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i5.Future deleteOrder(String? orderId) => - (super.noSuchMethod( - Invocation.method(#deleteOrder, [orderId]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i6.Future deleteOrder(String? orderId) => (super.noSuchMethod( + Invocation.method( + #deleteOrder, + [orderId], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i5.Future> getAllOrders() => - (super.noSuchMethod( - Invocation.method(#getAllOrders, []), - returnValue: _i5.Future>.value( - <_i10.NostrEvent>[], - ), - ) - as _i5.Future>); + _i6.Future> getAllOrders() => (super.noSuchMethod( + Invocation.method( + #getAllOrders, + [], + ), + returnValue: _i6.Future>.value(<_i9.NostrEvent>[]), + ) as _i6.Future>); @override - _i5.Future updateOrder(_i10.NostrEvent? order) => - (super.noSuchMethod( - Invocation.method(#updateOrder, [order]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) - as _i5.Future); + _i6.Future updateOrder(_i9.NostrEvent? order) => (super.noSuchMethod( + Invocation.method( + #updateOrder, + [order], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - void updateSettings(_i8.Settings? settings) => super.noSuchMethod( - Invocation.method(#updateSettings, [settings]), - returnValueForMissingStub: null, - ); + void updateSettings(_i7.Settings? settings) => super.noSuchMethod( + Invocation.method( + #updateSettings, + [settings], + ), + returnValueForMissingStub: null, + ); } diff --git a/test/services/mostro_service_test.mocks.dart b/test/services/mostro_service_test.mocks.dart index 7749e35b..d2480050 100644 --- a/test/services/mostro_service_test.mocks.dart +++ b/test/services/mostro_service_test.mocks.dart @@ -36,41 +36,75 @@ import 'package:state_notifier/state_notifier.dart' as _i15; // ignore_for_file: subtype_of_sealed_class class _FakeSettings_0 extends _i1.SmartFake implements _i2.Settings { - _FakeSettings_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeSettings_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeNostrKeyPairs_1 extends _i1.SmartFake implements _i3.NostrKeyPairs { - _FakeNostrKeyPairs_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeNostrKeyPairs_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeNostrEvent_2 extends _i1.SmartFake implements _i3.NostrEvent { - _FakeNostrEvent_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeNostrEvent_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeSession_3 extends _i1.SmartFake implements _i4.Session { - _FakeSession_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeSession_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeDatabase_4 extends _i1.SmartFake implements _i5.Database { - _FakeDatabase_4(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeDatabase_4( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeStoreRef_5 - extends _i1.SmartFake - implements _i5.StoreRef { - _FakeStoreRef_5(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + extends _i1.SmartFake implements _i5.StoreRef { + _FakeStoreRef_5( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeMostroMessage_6 extends _i1.SmartFake implements _i7.MostroMessage { - _FakeMostroMessage_6(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeMostroMessage_6( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [NostrService]. @@ -82,120 +116,145 @@ class MockNostrService extends _i1.Mock implements _i8.NostrService { } @override - _i2.Settings get settings => - (super.noSuchMethod( - Invocation.getter(#settings), - returnValue: _FakeSettings_0(this, Invocation.getter(#settings)), - ) - as _i2.Settings); + _i2.Settings get settings => (super.noSuchMethod( + Invocation.getter(#settings), + returnValue: _FakeSettings_0( + this, + Invocation.getter(#settings), + ), + ) as _i2.Settings); @override set settings(_i2.Settings? _settings) => super.noSuchMethod( - Invocation.setter(#settings, _settings), - returnValueForMissingStub: null, - ); + Invocation.setter( + #settings, + _settings, + ), + returnValueForMissingStub: null, + ); @override - bool get isInitialized => - (super.noSuchMethod(Invocation.getter(#isInitialized), returnValue: false) - as bool); + bool get isInitialized => (super.noSuchMethod( + Invocation.getter(#isInitialized), + returnValue: false, + ) as bool); @override - _i9.Future init() => - (super.noSuchMethod( - Invocation.method(#init, []), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + _i9.Future init(_i2.Settings? settings) => (super.noSuchMethod( + Invocation.method( + #init, + [settings], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override _i9.Future updateSettings(_i2.Settings? newSettings) => (super.noSuchMethod( - Invocation.method(#updateSettings, [newSettings]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + Invocation.method( + #updateSettings, + [newSettings], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override _i9.Future<_i10.RelayInformations?> getRelayInfo(String? relayUrl) => (super.noSuchMethod( - Invocation.method(#getRelayInfo, [relayUrl]), - returnValue: _i9.Future<_i10.RelayInformations?>.value(), - ) - as _i9.Future<_i10.RelayInformations?>); + Invocation.method( + #getRelayInfo, + [relayUrl], + ), + returnValue: _i9.Future<_i10.RelayInformations?>.value(), + ) as _i9.Future<_i10.RelayInformations?>); @override - _i9.Future publishEvent(_i3.NostrEvent? event) => - (super.noSuchMethod( - Invocation.method(#publishEvent, [event]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + _i9.Future publishEvent(_i3.NostrEvent? event) => (super.noSuchMethod( + Invocation.method( + #publishEvent, + [event], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override _i9.Future> fecthEvents(_i3.NostrFilter? filter) => (super.noSuchMethod( - Invocation.method(#fecthEvents, [filter]), - returnValue: _i9.Future>.value( - <_i3.NostrEvent>[], - ), - ) - as _i9.Future>); + Invocation.method( + #fecthEvents, + [filter], + ), + returnValue: _i9.Future>.value(<_i3.NostrEvent>[]), + ) as _i9.Future>); @override _i9.Stream<_i3.NostrEvent> subscribeToEvents(_i3.NostrFilter? filter) => (super.noSuchMethod( - Invocation.method(#subscribeToEvents, [filter]), - returnValue: _i9.Stream<_i3.NostrEvent>.empty(), - ) - as _i9.Stream<_i3.NostrEvent>); - - @override - _i9.Future disconnectFromRelays() => - (super.noSuchMethod( - Invocation.method(#disconnectFromRelays, []), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); - - @override - _i9.Future<_i3.NostrKeyPairs> generateKeyPair() => - (super.noSuchMethod( - Invocation.method(#generateKeyPair, []), - returnValue: _i9.Future<_i3.NostrKeyPairs>.value( - _FakeNostrKeyPairs_1( - this, - Invocation.method(#generateKeyPair, []), - ), - ), - ) - as _i9.Future<_i3.NostrKeyPairs>); + Invocation.method( + #subscribeToEvents, + [filter], + ), + returnValue: _i9.Stream<_i3.NostrEvent>.empty(), + ) as _i9.Stream<_i3.NostrEvent>); + + @override + _i9.Future disconnectFromRelays() => (super.noSuchMethod( + Invocation.method( + #disconnectFromRelays, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future<_i3.NostrKeyPairs> generateKeyPair() => (super.noSuchMethod( + Invocation.method( + #generateKeyPair, + [], + ), + returnValue: _i9.Future<_i3.NostrKeyPairs>.value(_FakeNostrKeyPairs_1( + this, + Invocation.method( + #generateKeyPair, + [], + ), + )), + ) as _i9.Future<_i3.NostrKeyPairs>); @override _i3.NostrKeyPairs generateKeyPairFromPrivateKey(String? privateKey) => (super.noSuchMethod( - Invocation.method(#generateKeyPairFromPrivateKey, [privateKey]), - returnValue: _FakeNostrKeyPairs_1( - this, - Invocation.method(#generateKeyPairFromPrivateKey, [privateKey]), - ), - ) - as _i3.NostrKeyPairs); - - @override - String getMostroPubKey() => - (super.noSuchMethod( - Invocation.method(#getMostroPubKey, []), - returnValue: _i11.dummyValue( - this, - Invocation.method(#getMostroPubKey, []), - ), - ) - as String); + Invocation.method( + #generateKeyPairFromPrivateKey, + [privateKey], + ), + returnValue: _FakeNostrKeyPairs_1( + this, + Invocation.method( + #generateKeyPairFromPrivateKey, + [privateKey], + ), + ), + ) as _i3.NostrKeyPairs); + + @override + String getMostroPubKey() => (super.noSuchMethod( + Invocation.method( + #getMostroPubKey, + [], + ), + returnValue: _i11.dummyValue( + this, + Invocation.method( + #getMostroPubKey, + [], + ), + ), + ) as String); @override _i9.Future<_i3.NostrEvent> createNIP59Event( @@ -204,23 +263,26 @@ class MockNostrService extends _i1.Mock implements _i8.NostrService { String? senderPrivateKey, ) => (super.noSuchMethod( - Invocation.method(#createNIP59Event, [ + Invocation.method( + #createNIP59Event, + [ + content, + recipientPubKey, + senderPrivateKey, + ], + ), + returnValue: _i9.Future<_i3.NostrEvent>.value(_FakeNostrEvent_2( + this, + Invocation.method( + #createNIP59Event, + [ content, recipientPubKey, senderPrivateKey, - ]), - returnValue: _i9.Future<_i3.NostrEvent>.value( - _FakeNostrEvent_2( - this, - Invocation.method(#createNIP59Event, [ - content, - recipientPubKey, - senderPrivateKey, - ]), - ), - ), - ) - as _i9.Future<_i3.NostrEvent>); + ], + ), + )), + ) as _i9.Future<_i3.NostrEvent>); @override _i9.Future<_i3.NostrEvent> decryptNIP59Event( @@ -228,15 +290,24 @@ class MockNostrService extends _i1.Mock implements _i8.NostrService { String? privateKey, ) => (super.noSuchMethod( - Invocation.method(#decryptNIP59Event, [event, privateKey]), - returnValue: _i9.Future<_i3.NostrEvent>.value( - _FakeNostrEvent_2( - this, - Invocation.method(#decryptNIP59Event, [event, privateKey]), - ), - ), - ) - as _i9.Future<_i3.NostrEvent>); + Invocation.method( + #decryptNIP59Event, + [ + event, + privateKey, + ], + ), + returnValue: _i9.Future<_i3.NostrEvent>.value(_FakeNostrEvent_2( + this, + Invocation.method( + #decryptNIP59Event, + [ + event, + privateKey, + ], + ), + )), + ) as _i9.Future<_i3.NostrEvent>); @override _i9.Future createRumor( @@ -246,25 +317,28 @@ class MockNostrService extends _i1.Mock implements _i8.NostrService { String? content, ) => (super.noSuchMethod( - Invocation.method(#createRumor, [ + Invocation.method( + #createRumor, + [ + senderKeyPair, + wrapperKey, + recipientPubKey, + content, + ], + ), + returnValue: _i9.Future.value(_i11.dummyValue( + this, + Invocation.method( + #createRumor, + [ senderKeyPair, wrapperKey, recipientPubKey, content, - ]), - returnValue: _i9.Future.value( - _i11.dummyValue( - this, - Invocation.method(#createRumor, [ - senderKeyPair, - wrapperKey, - recipientPubKey, - content, - ]), - ), - ), - ) - as _i9.Future); + ], + ), + )), + ) as _i9.Future); @override _i9.Future createSeal( @@ -274,25 +348,28 @@ class MockNostrService extends _i1.Mock implements _i8.NostrService { String? encryptedContent, ) => (super.noSuchMethod( - Invocation.method(#createSeal, [ + Invocation.method( + #createSeal, + [ + senderKeyPair, + wrapperKey, + recipientPubKey, + encryptedContent, + ], + ), + returnValue: _i9.Future.value(_i11.dummyValue( + this, + Invocation.method( + #createSeal, + [ senderKeyPair, wrapperKey, recipientPubKey, encryptedContent, - ]), - returnValue: _i9.Future.value( - _i11.dummyValue( - this, - Invocation.method(#createSeal, [ - senderKeyPair, - wrapperKey, - recipientPubKey, - encryptedContent, - ]), - ), - ), - ) - as _i9.Future); + ], + ), + )), + ) as _i9.Future); @override _i9.Future<_i3.NostrEvent> createWrap( @@ -301,23 +378,26 @@ class MockNostrService extends _i1.Mock implements _i8.NostrService { String? recipientPubKey, ) => (super.noSuchMethod( - Invocation.method(#createWrap, [ + Invocation.method( + #createWrap, + [ + wrapperKeyPair, + sealedContent, + recipientPubKey, + ], + ), + returnValue: _i9.Future<_i3.NostrEvent>.value(_FakeNostrEvent_2( + this, + Invocation.method( + #createWrap, + [ wrapperKeyPair, sealedContent, recipientPubKey, - ]), - returnValue: _i9.Future<_i3.NostrEvent>.value( - _FakeNostrEvent_2( - this, - Invocation.method(#createWrap, [ - wrapperKeyPair, - sealedContent, - recipientPubKey, - ]), - ), - ), - ) - as _i9.Future<_i3.NostrEvent>); + ], + ), + )), + ) as _i9.Future<_i3.NostrEvent>); } /// A class which mocks [SessionNotifier]. @@ -329,155 +409,192 @@ class MockSessionNotifier extends _i1.Mock implements _i12.SessionNotifier { } @override - List<_i4.Session> get sessions => - (super.noSuchMethod( - Invocation.getter(#sessions), - returnValue: <_i4.Session>[], - ) - as List<_i4.Session>); + List<_i4.Session> get sessions => (super.noSuchMethod( + Invocation.getter(#sessions), + returnValue: <_i4.Session>[], + ) as List<_i4.Session>); @override set onError(_i13.ErrorListener? _onError) => super.noSuchMethod( - Invocation.setter(#onError, _onError), - returnValueForMissingStub: null, - ); + Invocation.setter( + #onError, + _onError, + ), + returnValueForMissingStub: null, + ); @override - bool get mounted => - (super.noSuchMethod(Invocation.getter(#mounted), returnValue: false) - as bool); + bool get mounted => (super.noSuchMethod( + Invocation.getter(#mounted), + returnValue: false, + ) as bool); @override - _i9.Stream> get stream => - (super.noSuchMethod( - Invocation.getter(#stream), - returnValue: _i9.Stream>.empty(), - ) - as _i9.Stream>); + _i9.Stream> get stream => (super.noSuchMethod( + Invocation.getter(#stream), + returnValue: _i9.Stream>.empty(), + ) as _i9.Stream>); @override - List<_i4.Session> get state => - (super.noSuchMethod( - Invocation.getter(#state), - returnValue: <_i4.Session>[], - ) - as List<_i4.Session>); + List<_i4.Session> get state => (super.noSuchMethod( + Invocation.getter(#state), + returnValue: <_i4.Session>[], + ) as List<_i4.Session>); @override set state(List<_i4.Session>? value) => super.noSuchMethod( - Invocation.setter(#state, value), - returnValueForMissingStub: null, - ); + Invocation.setter( + #state, + value, + ), + returnValueForMissingStub: null, + ); @override - List<_i4.Session> get debugState => - (super.noSuchMethod( - Invocation.getter(#debugState), - returnValue: <_i4.Session>[], - ) - as List<_i4.Session>); + List<_i4.Session> get debugState => (super.noSuchMethod( + Invocation.getter(#debugState), + returnValue: <_i4.Session>[], + ) as List<_i4.Session>); @override - bool get hasListeners => - (super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false) - as bool); + bool get hasListeners => (super.noSuchMethod( + Invocation.getter(#hasListeners), + returnValue: false, + ) as bool); @override - _i9.Future init() => - (super.noSuchMethod( - Invocation.method(#init, []), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + _i9.Future init() => (super.noSuchMethod( + Invocation.method( + #init, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override void updateSettings(_i2.Settings? settings) => super.noSuchMethod( - Invocation.method(#updateSettings, [settings]), - returnValueForMissingStub: null, - ); + Invocation.method( + #updateSettings, + [settings], + ), + returnValueForMissingStub: null, + ); @override - _i9.Future<_i4.Session> newSession({String? orderId, _i14.Role? role}) => + _i9.Future<_i4.Session> newSession({ + String? orderId, + _i14.Role? role, + }) => (super.noSuchMethod( - Invocation.method(#newSession, [], { + Invocation.method( + #newSession, + [], + { + #orderId: orderId, + #role: role, + }, + ), + returnValue: _i9.Future<_i4.Session>.value(_FakeSession_3( + this, + Invocation.method( + #newSession, + [], + { #orderId: orderId, #role: role, - }), - returnValue: _i9.Future<_i4.Session>.value( - _FakeSession_3( - this, - Invocation.method(#newSession, [], { - #orderId: orderId, - #role: role, - }), - ), - ), - ) - as _i9.Future<_i4.Session>); + }, + ), + )), + ) as _i9.Future<_i4.Session>); @override - _i9.Future saveSession(_i4.Session? session) => - (super.noSuchMethod( - Invocation.method(#saveSession, [session]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + _i9.Future saveSession(_i4.Session? session) => (super.noSuchMethod( + Invocation.method( + #saveSession, + [session], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override _i4.Session? getSessionByOrderId(String? orderId) => - (super.noSuchMethod(Invocation.method(#getSessionByOrderId, [orderId])) - as _i4.Session?); - - @override - _i9.Future<_i4.Session?> loadSession(int? keyIndex) => - (super.noSuchMethod( - Invocation.method(#loadSession, [keyIndex]), - returnValue: _i9.Future<_i4.Session?>.value(), - ) - as _i9.Future<_i4.Session?>); - - @override - _i9.Future reset() => - (super.noSuchMethod( - Invocation.method(#reset, []), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); - - @override - _i9.Future deleteSession(String? sessionId) => - (super.noSuchMethod( - Invocation.method(#deleteSession, [sessionId]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); - - @override - _i9.Future clearExpiredSessions() => - (super.noSuchMethod( - Invocation.method(#clearExpiredSessions, []), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + (super.noSuchMethod(Invocation.method( + #getSessionByOrderId, + [orderId], + )) as _i4.Session?); + + @override + _i4.Session? getSessionByTradeKey(String? tradeKey) => + (super.noSuchMethod(Invocation.method( + #getSessionByTradeKey, + [tradeKey], + )) as _i4.Session?); + + @override + _i9.Future<_i4.Session?> loadSession(int? keyIndex) => (super.noSuchMethod( + Invocation.method( + #loadSession, + [keyIndex], + ), + returnValue: _i9.Future<_i4.Session?>.value(), + ) as _i9.Future<_i4.Session?>); + + @override + _i9.Future reset() => (super.noSuchMethod( + Invocation.method( + #reset, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future deleteSession(String? sessionId) => (super.noSuchMethod( + Invocation.method( + #deleteSession, + [sessionId], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); + + @override + _i9.Future clearExpiredSessions() => (super.noSuchMethod( + Invocation.method( + #clearExpiredSessions, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); @override - bool updateShouldNotify(List<_i4.Session>? old, List<_i4.Session>? current) => + bool updateShouldNotify( + List<_i4.Session>? old, + List<_i4.Session>? current, + ) => (super.noSuchMethod( - Invocation.method(#updateShouldNotify, [old, current]), - returnValue: false, - ) - as bool); + Invocation.method( + #updateShouldNotify, + [ + old, + current, + ], + ), + returnValue: false, + ) as bool); @override _i13.RemoveListener addListener( @@ -485,14 +602,13 @@ class MockSessionNotifier extends _i1.Mock implements _i12.SessionNotifier { bool? fireImmediately = true, }) => (super.noSuchMethod( - Invocation.method( - #addListener, - [listener], - {#fireImmediately: fireImmediately}, - ), - returnValue: () {}, - ) - as _i13.RemoveListener); + Invocation.method( + #addListener, + [listener], + {#fireImmediately: fireImmediately}, + ), + returnValue: () {}, + ) as _i13.RemoveListener); } /// A class which mocks [MostroStorage]. @@ -504,112 +620,122 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { } @override - _i5.Database get db => - (super.noSuchMethod( - Invocation.getter(#db), - returnValue: _FakeDatabase_4(this, Invocation.getter(#db)), - ) - as _i5.Database); + _i5.Database get db => (super.noSuchMethod( + Invocation.getter(#db), + returnValue: _FakeDatabase_4( + this, + Invocation.getter(#db), + ), + ) as _i5.Database); @override - _i5.StoreRef> get store => - (super.noSuchMethod( - Invocation.getter(#store), - returnValue: _FakeStoreRef_5>( - this, - Invocation.getter(#store), - ), - ) - as _i5.StoreRef>); + _i5.StoreRef> get store => (super.noSuchMethod( + Invocation.getter(#store), + returnValue: _FakeStoreRef_5>( + this, + Invocation.getter(#store), + ), + ) as _i5.StoreRef>); @override _i9.Future addMessage(_i7.MostroMessage<_i6.Payload>? message) => (super.noSuchMethod( - Invocation.method(#addMessage, [message]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + Invocation.method( + #addMessage, + [message], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override _i9.Future addMessages( - List<_i7.MostroMessage<_i6.Payload>>? messages, - ) => + List<_i7.MostroMessage<_i6.Payload>>? messages) => (super.noSuchMethod( - Invocation.method(#addMessages, [messages]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + Invocation.method( + #addMessages, + [messages], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override _i9.Future<_i7.MostroMessage<_i6.Payload>?> - getMessageById(String? orderId) => - (super.noSuchMethod( - Invocation.method(#getMessageById, [orderId]), + getMessageById(String? orderId) => + (super.noSuchMethod( + Invocation.method( + #getMessageById, + [orderId], + ), returnValue: _i9.Future<_i7.MostroMessage<_i6.Payload>?>.value(), - ) - as _i9.Future<_i7.MostroMessage<_i6.Payload>?>); + ) as _i9.Future<_i7.MostroMessage<_i6.Payload>?>); @override _i9.Future>> getAllMessages() => (super.noSuchMethod( - Invocation.method(#getAllMessages, []), - returnValue: _i9.Future>>.value( - <_i7.MostroMessage<_i6.Payload>>[], - ), - ) - as _i9.Future>>); + Invocation.method( + #getAllMessages, + [], + ), + returnValue: _i9.Future>>.value( + <_i7.MostroMessage<_i6.Payload>>[]), + ) as _i9.Future>>); @override _i9.Future deleteMessage(String? orderId) => (super.noSuchMethod( - Invocation.method(#deleteMessage, [orderId]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + Invocation.method( + #deleteMessage, + [orderId], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override - _i9.Future deleteAllMessages() => - (super.noSuchMethod( - Invocation.method(#deleteAllMessages, []), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + _i9.Future deleteAllMessages() => (super.noSuchMethod( + Invocation.method( + #deleteAllMessages, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override _i9.Future deleteAllMessagesById(String? orderId) => (super.noSuchMethod( - Invocation.method(#deleteAllMessagesById, [orderId]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + Invocation.method( + #deleteAllMessagesById, + [orderId], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override _i9.Future>> - getMessagesOfType() => - (super.noSuchMethod( - Invocation.method(#getMessagesOfType, []), - returnValue: _i9.Future>>.value( - <_i7.MostroMessage>[], + getMessagesOfType() => (super.noSuchMethod( + Invocation.method( + #getMessagesOfType, + [], ), - ) - as _i9.Future>>); + returnValue: _i9.Future>>.value( + <_i7.MostroMessage>[]), + ) as _i9.Future>>); @override _i9.Future>> getMessagesForId( - String? orderId, - ) => + String? orderId) => (super.noSuchMethod( - Invocation.method(#getMessagesForId, [orderId]), - returnValue: _i9.Future>>.value( - <_i7.MostroMessage<_i6.Payload>>[], - ), - ) - as _i9.Future>>); + Invocation.method( + #getMessagesForId, + [orderId], + ), + returnValue: _i9.Future>>.value( + <_i7.MostroMessage<_i6.Payload>>[]), + ) as _i9.Future>>); @override _i7.MostroMessage<_i6.Payload> fromDbMap( @@ -617,85 +743,126 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { Map? jsonMap, ) => (super.noSuchMethod( - Invocation.method(#fromDbMap, [key, jsonMap]), - returnValue: _FakeMostroMessage_6<_i6.Payload>( - this, - Invocation.method(#fromDbMap, [key, jsonMap]), - ), - ) - as _i7.MostroMessage<_i6.Payload>); + Invocation.method( + #fromDbMap, + [ + key, + jsonMap, + ], + ), + returnValue: _FakeMostroMessage_6<_i6.Payload>( + this, + Invocation.method( + #fromDbMap, + [ + key, + jsonMap, + ], + ), + ), + ) as _i7.MostroMessage<_i6.Payload>); @override Map toDbMap(_i7.MostroMessage<_i6.Payload>? item) => (super.noSuchMethod( - Invocation.method(#toDbMap, [item]), - returnValue: {}, - ) - as Map); - - @override - String messageKey(_i7.MostroMessage<_i6.Payload>? msg) => - (super.noSuchMethod( - Invocation.method(#messageKey, [msg]), - returnValue: _i11.dummyValue( - this, - Invocation.method(#messageKey, [msg]), - ), - ) - as String); - - @override - _i9.Future putItem(String? id, _i7.MostroMessage<_i6.Payload>? item) => + Invocation.method( + #toDbMap, + [item], + ), + returnValue: {}, + ) as Map); + + @override + String messageKey(_i7.MostroMessage<_i6.Payload>? msg) => (super.noSuchMethod( + Invocation.method( + #messageKey, + [msg], + ), + returnValue: _i11.dummyValue( + this, + Invocation.method( + #messageKey, + [msg], + ), + ), + ) as String); + + @override + _i9.Future hasMessage(_i7.MostroMessage<_i6.Payload>? msg) => + (super.noSuchMethod( + Invocation.method( + #hasMessage, + [msg], + ), + returnValue: _i9.Future.value(false), + ) as _i9.Future); + + @override + _i9.Future putItem( + String? id, + _i7.MostroMessage<_i6.Payload>? item, + ) => (super.noSuchMethod( - Invocation.method(#putItem, [id, item]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + Invocation.method( + #putItem, + [ + id, + item, + ], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override _i9.Future<_i7.MostroMessage<_i6.Payload>?> getItem(String? id) => (super.noSuchMethod( - Invocation.method(#getItem, [id]), - returnValue: _i9.Future<_i7.MostroMessage<_i6.Payload>?>.value(), - ) - as _i9.Future<_i7.MostroMessage<_i6.Payload>?>); + Invocation.method( + #getItem, + [id], + ), + returnValue: _i9.Future<_i7.MostroMessage<_i6.Payload>?>.value(), + ) as _i9.Future<_i7.MostroMessage<_i6.Payload>?>); @override - _i9.Future hasItem(String? id) => - (super.noSuchMethod( - Invocation.method(#hasItem, [id]), - returnValue: _i9.Future.value(false), - ) - as _i9.Future); + _i9.Future hasItem(String? id) => (super.noSuchMethod( + Invocation.method( + #hasItem, + [id], + ), + returnValue: _i9.Future.value(false), + ) as _i9.Future); @override _i9.Future>> getAllItems() => (super.noSuchMethod( - Invocation.method(#getAllItems, []), - returnValue: _i9.Future>>.value( - <_i7.MostroMessage<_i6.Payload>>[], - ), - ) - as _i9.Future>>); + Invocation.method( + #getAllItems, + [], + ), + returnValue: _i9.Future>>.value( + <_i7.MostroMessage<_i6.Payload>>[]), + ) as _i9.Future>>); @override - _i9.Future deleteItem(String? id) => - (super.noSuchMethod( - Invocation.method(#deleteItem, [id]), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + _i9.Future deleteItem(String? id) => (super.noSuchMethod( + Invocation.method( + #deleteItem, + [id], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override - _i9.Future deleteAllItems() => - (super.noSuchMethod( - Invocation.method(#deleteAllItems, []), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) - as _i9.Future); + _i9.Future deleteAllItems() => (super.noSuchMethod( + Invocation.method( + #deleteAllItems, + [], + ), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) as _i9.Future); @override _i9.Future> deleteWhere( @@ -703,18 +870,30 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { int? maxBatchSize, }) => (super.noSuchMethod( - Invocation.method( - #deleteWhere, - [filter], - {#maxBatchSize: maxBatchSize}, - ), - returnValue: _i9.Future>.value([]), - ) - as _i9.Future>); + Invocation.method( + #deleteWhere, + [filter], + {#maxBatchSize: maxBatchSize}, + ), + returnValue: _i9.Future>.value([]), + ) as _i9.Future>); + + @override + _i9.Stream>> watch() => + (super.noSuchMethod( + Invocation.method( + #watch, + [], + ), + returnValue: _i9.Stream>>.empty(), + ) as _i9.Stream>>); @override void dispose() => super.noSuchMethod( - Invocation.method(#dispose, []), - returnValueForMissingStub: null, - ); + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); } From 8b3d5d030b22d59b41c5600758f45de9a523c30d Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 19 Apr 2025 19:54:30 +1000 Subject: [PATCH 097/149] add isRunning property to BackgroundService --- lib/background/abstract_background_service.dart | 1 + lib/background/background.dart | 6 ++++-- lib/background/desktop_background_service.dart | 3 +++ lib/background/mobile_background_service.dart | 9 +++++++++ .../order/providers/order_notifier_provider.dart | 2 +- 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/background/abstract_background_service.dart b/lib/background/abstract_background_service.dart index 22a049e4..08620f43 100644 --- a/lib/background/abstract_background_service.dart +++ b/lib/background/abstract_background_service.dart @@ -10,4 +10,5 @@ abstract class BackgroundService { Future getActiveSubscriptionCount(); void setForegroundStatus(bool isForeground); Stream get eventsStream; + bool get isRunning; } diff --git a/lib/background/background.dart b/lib/background/background.dart index e216190e..c87d8252 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -7,7 +7,6 @@ import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/nostr_filter.dart'; import 'package:mostro_mobile/data/repositories/event_storage.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; -import 'package:mostro_mobile/notifications/notification_service.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; import 'package:path/path.dart' as p; @@ -17,7 +16,6 @@ bool isAppForeground = false; @pragma('vm:entry-point') Future serviceMain(ServiceInstance service) async { - final dir = await getApplicationSupportDirectory(); final path = p.join(dir.path, 'mostro', 'databases', 'background.db'); @@ -96,6 +94,10 @@ Future serviceMain(ServiceInstance service) async { activeSubscriptions.remove(id); } }); + + service.invoke('is-running', { + 'is-running': true, + }); } @pragma('vm:entry-point') diff --git a/lib/background/desktop_background_service.dart b/lib/background/desktop_background_service.dart index 870f3341..0cc887bb 100644 --- a/lib/background/desktop_background_service.dart +++ b/lib/background/desktop_background_service.dart @@ -177,4 +177,7 @@ class DesktopBackgroundService implements BackgroundService { @override Stream get eventsStream => _eventsController.stream; + + @override + bool get isRunning => _isRunning; } diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index 32402cf9..68509f19 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -36,6 +36,12 @@ class MobileBackgroundService implements BackgroundService { ); }); + service.on('event').listen((data) { + _eventsController.add( + NostrEventExtensions.fromMap(data!), + ); + }); + service.startService(); } @@ -129,4 +135,7 @@ class MobileBackgroundService implements BackgroundService { @override Stream get eventsStream => _eventsController.stream; + + @override + bool get isRunning => _isRunning; } diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index ca10084d..96c28c61 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -74,7 +74,7 @@ class OrderTypeNotifier extends _$OrderTypeNotifier { final addOrderEventsProvider = StreamProvider.family( (ref, requestId) { - final bus = ref.watch(eventBusProvider); + final bus = ref.read(eventBusProvider); return bus.stream.where( (msg) => msg.requestId == requestId, ); From f2c8beac71ceae4781a8a1128db588b978d99c2b Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 19 Apr 2025 22:53:07 +1000 Subject: [PATCH 098/149] Remove extra event call --- lib/background/mobile_background_service.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index 68509f19..3431d5f5 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -36,12 +36,6 @@ class MobileBackgroundService implements BackgroundService { ); }); - service.on('event').listen((data) { - _eventsController.add( - NostrEventExtensions.fromMap(data!), - ); - }); - service.startService(); } From 6b2faf2169f25cfdd1572a73ee44e8c1ddb57637 Mon Sep 17 00:00:00 2001 From: Biz Date: Sun, 20 Apr 2025 13:08:20 +1000 Subject: [PATCH 099/149] Add lifecycle manager and refactor background service database paths --- lib/background/background.dart | 6 +- .../desktop_background_service.dart | 9 +-- lib/background/mobile_background_service.dart | 40 ++++------ lib/main.dart | 6 +- lib/services/lifecycle_manager.dart | 78 +++++++++++++++++++ lib/services/mostro_service.dart | 30 ++++--- lib/services/nostr_service.dart | 35 ++++++++- .../providers/mostro_database_provider.dart | 8 +- 8 files changed, 154 insertions(+), 58 deletions(-) create mode 100644 lib/services/lifecycle_manager.dart diff --git a/lib/background/background.dart b/lib/background/background.dart index c87d8252..df97f1ce 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -9,15 +9,11 @@ import 'package:mostro_mobile/data/repositories/event_storage.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; bool isAppForeground = false; @pragma('vm:entry-point') Future serviceMain(ServiceInstance service) async { - final dir = await getApplicationSupportDirectory(); - final path = p.join(dir.path, 'mostro', 'databases', 'background.db'); // If on Android, set up a permanent notification so the OS won't kill it. if (service is AndroidServiceInstance) { @@ -46,7 +42,7 @@ Future serviceMain(ServiceInstance service) async { final Map> activeSubscriptions = {}; final nostrService = NostrService(); - final db = await openMostroDatabase(path); + final db = await openMostroDatabase('background.db'); final backgroundStorage = EventStorage(db: db); service.on('app-foreground-status').listen((data) { diff --git a/lib/background/desktop_background_service.dart b/lib/background/desktop_background_service.dart index 0cc887bb..84c89054 100644 --- a/lib/background/desktop_background_service.dart +++ b/lib/background/desktop_background_service.dart @@ -9,8 +9,6 @@ import 'package:mostro_mobile/data/repositories.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import 'abstract_background_service.dart'; class DesktopBackgroundService implements BackgroundService { @@ -23,11 +21,9 @@ class DesktopBackgroundService implements BackgroundService { @override Future initialize(Settings settings) async { final token = ServicesBinding.rootIsolateToken!; - final dir = await getApplicationSupportDirectory(); - final path = p.join(dir.path, 'mostro', 'databases', 'background.db'); final receivePort = ReceivePort(); - await Isolate.spawn(_isolateEntry, [receivePort.sendPort, token, path]); + await Isolate.spawn(_isolateEntry, [receivePort.sendPort, token]); receivePort.listen((message) { if (message is SendPort) { @@ -47,14 +43,13 @@ class DesktopBackgroundService implements BackgroundService { final isolateReceivePort = ReceivePort(); final mainSendPort = args[0] as SendPort; final token = args[1] as RootIsolateToken; - final dbPath = args[2] as String; mainSendPort.send(isolateReceivePort.sendPort); BackgroundIsolateBinaryMessenger.ensureInitialized(token); final nostrService = NostrService(); - final db = await openMostroDatabase(dbPath); + final db = await openMostroDatabase('background.db'); final backgroundStorage = EventStorage(db: db); final logger = Logger(); bool isAppForeground = true; diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index 3431d5f5..d70230c4 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -41,12 +41,9 @@ class MobileBackgroundService implements BackgroundService { @override Future subscribe(Map filter) async { - service.invoke( - 'create-subscription', - { - 'filter': filter, - }, - ); + service.invoke('create-subscription', { + 'filter': filter, + }); return true; } @@ -58,12 +55,9 @@ class MobileBackgroundService implements BackgroundService { } _subscriptions.remove(subscriptionId); - service.invoke( - 'cancel-subscription', - { - 'id': subscriptionId, - }, - ); + service.invoke('cancel-subscription', { + 'id': subscriptionId, + }); // If no more subscriptions, stop the service if (_subscriptions.isEmpty && _isRunning) { @@ -101,13 +95,10 @@ class MobileBackgroundService implements BackgroundService { // Re-register all active subscriptions for (final entry in _subscriptions.entries) { - service.invoke( - 'create-subscription', - { - 'filter': entry.value, - 'id': entry.key, - }, - ); + service.invoke('create-subscription', { + 'filter': entry.value, + 'id': entry.key, + }); } } @@ -119,17 +110,14 @@ class MobileBackgroundService implements BackgroundService { @override void updateSettings(Settings settings) { - service.invoke( - 'settings-change', - { - 'settings': settings.toJson(), - }, - ); + service.invoke('settings-change', { + 'settings': settings.toJson(), + }); } @override Stream get eventsStream => _eventsController.stream; - + @override bool get isRunning => _isRunning; } diff --git a/lib/main.dart b/lib/main.dart index 8709df7d..c94d209a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,8 +10,6 @@ import 'package:mostro_mobile/notifications/notification_service.dart'; import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; import 'package:mostro_mobile/shared/providers/providers.dart'; import 'package:mostro_mobile/shared/utils/biometrics_helper.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; Future main() async { @@ -21,9 +19,7 @@ Future main() async { final sharedPreferences = SharedPreferencesAsync(); final secureStorage = const FlutterSecureStorage(); - final dir = await getApplicationSupportDirectory(); - final path = p.join(dir.path, 'mostro', 'databases', 'mostro.db'); - final database = await openMostroDatabase(path); + final database = await openMostroDatabase('mostro.db'); final settings = SettingsNotifier(sharedPreferences); await settings.init(); diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart new file mode 100644 index 00000000..b77808b9 --- /dev/null +++ b/lib/services/lifecycle_manager.dart @@ -0,0 +1,78 @@ +import 'package:dart_nostr/nostr/model/request/filter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; + +class LifecycleManager extends WidgetsBindingObserver { + final Ref ref; + bool _isInBackground = false; + final List _activeSubscriptions = []; + + LifecycleManager(this.ref) { + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) async { + switch (state) { + case AppLifecycleState.resumed: + // App is in foreground + if (_isInBackground) { + await _switchToForeground(); + } + break; + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + case AppLifecycleState.detached: + // App is in background + if (!_isInBackground) { + await _switchToBackground(); + } + break; + default: + break; + } + } + + Future _switchToForeground() async { + _isInBackground = false; + + // Stop background service + final backgroundService = ref.read(backgroundServiceProvider); + backgroundService.setForegroundStatus(true); + + await ref.read(nostrServiceProvider).syncBackgroundEvents(); + + // Re-establish direct subscriptions + final nostrService = ref.read(nostrServiceProvider); + for (final subscription in _activeSubscriptions) { + nostrService.subscribeToEvents(subscription); + } + } + + Future _switchToBackground() async { + _isInBackground = true; + + // Transfer active subscriptions to background service + final backgroundService = ref.read(backgroundServiceProvider); + backgroundService.setForegroundStatus(false); + + for (final subscription in _activeSubscriptions) { + await backgroundService.subscribe(subscription.toMap()); + } + } + + void addSubscription(NostrFilter filter) { + _activeSubscriptions.add(filter); + final nostrService = ref.read(nostrServiceProvider); + nostrService.subscribeToEvents(filter); + } + + void dispose() { + WidgetsBinding.instance.removeObserver(this); + } +} + +// Provider for the lifecycle manager +final lifecycleManagerProvider = Provider((ref) => LifecycleManager(ref)); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 2cb97901..46bf7123 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; 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:logger/logger.dart'; import 'package:mostro_mobile/background/abstract_background_service.dart'; @@ -8,6 +9,7 @@ import 'package:mostro_mobile/data/repositories.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/event_bus.dart'; +import 'package:mostro_mobile/services/lifecycle_manager.dart'; import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; @@ -33,12 +35,25 @@ class MostroService { this.backgroundService, ) : _settings = ref.read(settingsProvider); - Future init() async { - backgroundService.eventsStream.listen((data) async { - await _handleIncomingEvent(data); - }); +Future init() async { + final sessions = _sessionNotifier.sessions; + for (final session in sessions) { + subscribe(session); } +} + +void subscribe(Session session) { + final filter = NostrFilter( + kinds: [1059], + authors: [session.tradeKey.public], + ); + + // Add subscription through lifecycle manager + ref.read(lifecycleManagerProvider).addSubscription(filter); +} +// Remove background service listening code from here +// It will be handled by the lifecycle manager Future _handleIncomingEvent(NostrEvent event) async { if (await _eventStorage.hasItem(event.id!)) return; await _eventStorage.putItem( @@ -78,13 +93,6 @@ class MostroService { _bus.emit(msg); } - void subscribe(Session session) { - backgroundService.subscribe({ - 'kinds': [1059], - '#p': [session.tradeKey.public], - }); - } - Session? getSessionByOrderId(String orderId) { return _sessionNotifier.getSessionByOrderId(orderId); } diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 7d9a6ea2..8d7874eb 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -3,7 +3,9 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:dart_nostr/nostr/model/relay_informations.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/data/repositories/event_storage.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class NostrService { @@ -22,6 +24,8 @@ class NostrService { relaysUrl: settings.relays, connectionTimeout: Config.nostrConnectionTimeout, shouldReconnectToRelayOnNotice: true, + retryOnClose: true, + retryOnError: true, onRelayListening: (relay, url, channel) { _logger.i('Connected to relay: $relay'); }, @@ -31,8 +35,6 @@ class NostrService { onRelayConnectionDone: (relay, socket) { _logger.i('Connection to relay: $relay via $socket is done'); }, - retryOnClose: true, - retryOnError: true, ); _isInitialized = true; _logger.i('Nostr initialized successfully'); @@ -117,7 +119,7 @@ class NostrService { } String getMostroPubKey() { - return Config.mostroPubKey; + return settings.mostroPublicKey; } Future createNIP59Event( @@ -173,4 +175,31 @@ class NostrService { recipientPubKey, ); } + +// Add method to sync background events + Future syncBackgroundEvents() async { + // Get the background database + final backgroundDb = await openMostroDatabase('background.db'); + final backgroundStorage = EventStorage(db: backgroundDb); + + // Get all events from background database + final events = await backgroundStorage.getAllItems(); + + // Process each event + for (final event in events) { + // Process event through your regular pipeline + // This might involve decrypting, parsing, and emitting to event bus + await processEvent(event); + } + + // Optionally clear background database after syncing + // await backgroundStorage.deleteAllItems(); + } + +// Add method to process events (similar to what was in MostroService) + Future processEvent(NostrEvent event) async { + // Your event processing logic here + // This would be similar to what was in MostroService._handleIncomingEvent + // but without the duplicate checking + } } diff --git a/lib/shared/providers/mostro_database_provider.dart b/lib/shared/providers/mostro_database_provider.dart index e2e4842d..94b79407 100644 --- a/lib/shared/providers/mostro_database_provider.dart +++ b/lib/shared/providers/mostro_database_provider.dart @@ -1,10 +1,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'package:sembast/sembast_io.dart'; Future openMostroDatabase(String dbName) async { //var factory = getDatabaseFactory(packageName: dbName); //final db = await factory.openDatabase(dbName); - final db = await databaseFactoryIo.openDatabase(dbName); + + final dir = await getApplicationSupportDirectory(); + final path = p.join(dir.path, 'mostro', 'databases', dbName); + + final db = await databaseFactoryIo.openDatabase(path); return db; } From 1280eefc6d7c906d526da7128af09383f17efad7 Mon Sep 17 00:00:00 2001 From: Biz Date: Mon, 21 Apr 2025 11:53:21 +1000 Subject: [PATCH 100/149] Revert MostrService and ChatRoomNotifier to subscribing directly in the foreground --- lib/background/mobile_background_service.dart | 3 -- .../chat/notifiers/chat_room_notifier.dart | 54 ++++++++----------- lib/services/mostro_service.dart | 29 ++++------ lib/shared/providers/app_init_provider.dart | 4 +- 4 files changed, 34 insertions(+), 56 deletions(-) diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index d70230c4..cd82ed5a 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -35,8 +35,6 @@ class MobileBackgroundService implements BackgroundService { NostrEventExtensions.fromMap(data!), ); }); - - service.startService(); } @override @@ -91,7 +89,6 @@ class MobileBackgroundService implements BackgroundService { Future _startService() async { await service.startService(); - _isRunning = true; // Re-register all active subscriptions for (final entry in _subscriptions.entries) { diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index f34ee812..232687e3 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -5,8 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; -import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; -import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; @@ -22,38 +20,30 @@ class ChatRoomNotifier extends StateNotifier { this.ref, ); - Future init() async { - final eventStore = ref.read(eventStorageProvider); - eventStore.watch().listen((data) { - data.forEach(_handleIncomingEvent); - }); - } - - void _handleIncomingEvent(NostrEvent event) async { - final session = ref.read(sessionProvider(event.subscriptionId!)); - if (session == null) return; - try { - final chat = await event.mostroUnWrap(session.sharedKey!); - if (!state.messages.contains(chat)) { - state = state.copy( - messages: [ - ...state.messages, - chat, - ], - ); - } - } catch (e) { - _logger.e(e); - } - } - void subscribe() { - final backgroundService = ref.read(backgroundServiceProvider); final session = ref.read(sessionProvider(orderId)); - backgroundService.subscribe({ - 'kinds' : [1059], - '#p': [session?.sharedKey!.public], - }); + final filter = NostrFilter( + kinds: [1059], + p: [session!.sharedKey!.public], + ); + subscription = + ref.read(nostrServiceProvider).subscribeToEvents(filter).listen( + (event) async { + try { + final chat = await event.mostroUnWrap(session.sharedKey!); + if (!state.messages.contains(chat)) { + state = state.copy( + messages: [ + ...state.messages, + chat, + ], + ); + } + } catch (e) { + _logger.e(e); + } + }, + ); } Future sendMessage(String text) async { diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 46bf7123..b73a84eb 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,8 +1,6 @@ import 'dart:convert'; -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:logger/logger.dart'; import 'package:mostro_mobile/background/abstract_background_service.dart'; import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/data/repositories.dart'; @@ -21,7 +19,6 @@ class MostroService { final MostroStorage _messageStorage; final EventBus _bus; - final _logger = Logger(); Settings _settings; final BackgroundService backgroundService; @@ -50,25 +47,20 @@ void subscribe(Session session) { // Add subscription through lifecycle manager ref.read(lifecycleManagerProvider).addSubscription(filter); -} -// Remove background service listening code from here -// It will be handled by the lifecycle manager - Future _handleIncomingEvent(NostrEvent event) async { + final nostrService = ref.read(nostrServiceProvider); + + nostrService.subscribeToEvents(filter).listen((event) async { + if (await _eventStorage.hasItem(event.id!)) return; await _eventStorage.putItem( event.id!, event, ); - final currentSession = _sessionNotifier.getSessionByTradeKey( - event.tags!.firstWhere((t) => t[0] == 'p')[1], - ); - if (currentSession == null) return; - // Process event as you currently do: final decryptedEvent = await event.unWrap( - currentSession.tradeKey.private, + session.tradeKey.private, ); if (decryptedEvent.content == null) return; @@ -81,16 +73,17 @@ void subscribe(Session session) { ref.read(orderActionNotifierProvider(msg.id!).notifier).set(msg.action); } if (msg.action == Action.canceled) { - await _messageStorage.deleteAllMessagesById(currentSession.orderId!); - await _sessionNotifier.deleteSession(currentSession.orderId!); + await _messageStorage.deleteAllMessagesById(session.orderId!); + await _sessionNotifier.deleteSession(session.orderId!); return; } await _messageStorage.addMessage(msg); - if (currentSession.orderId == null && msg.id != null) { - currentSession.orderId = msg.id; - await _sessionNotifier.saveSession(currentSession); + if (session.orderId == null && msg.id != null) { + session.orderId = msg.id; + await _sessionNotifier.saveSession(session); } _bus.emit(msg); + }); } Session? getSessionByOrderId(String orderId) { diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 174b499c..94205694 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -38,6 +38,7 @@ final appInitializerProvider = FutureProvider((ref) async { }); final mostroStorage = ref.read(mostroStorageProvider); + await mostroService.init(); for (final session in sessionManager.sessions) { if (session.orderId != null) { @@ -50,15 +51,12 @@ final appInitializerProvider = FutureProvider((ref) async { ref.read( orderNotifierProvider(session.orderId!), ); - await mostroService.init(); - mostroService.subscribe(session); } if (session.peer != null) { final chat = ref.watch( chatRoomsProvider(session.orderId!).notifier, ); - await chat.init(); chat.subscribe(); } } From bd6a5d6d5f681de4d4b81cff1b9f3ca0419b3738 Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 22 Apr 2025 11:39:46 +1000 Subject: [PATCH 101/149] Set background services to only run on app suspend --- android/app/src/main/AndroidManifest.xml | 4 ++ .../drawable-hdpi/ic_stat_notifcations.png | Bin 0 -> 1202 bytes .../drawable-mdpi/ic_stat_notifcations.png | Bin 0 -> 787 bytes .../drawable-xhdpi/ic_stat_notifcations.png | Bin 0 -> 1885 bytes .../drawable-xxhdpi/ic_stat_notifcations.png | Bin 0 -> 2859 bytes .../drawable-xxxhdpi/ic_stat_notifcations.png | Bin 0 -> 4414 bytes .../abstract_background_service.dart | 4 +- lib/background/background.dart | 12 +++-- lib/background/background_service.dart | 5 ++- .../desktop_background_service.dart | 29 +++--------- lib/background/mobile_background_service.dart | 42 +++++++++--------- .../order/notfiers/add_order_notifier.dart | 1 - lib/main.dart | 4 +- .../foreground_service_controller.dart | 23 ---------- lib/notifications/notification_service.dart | 2 +- lib/services/lifecycle_manager.dart | 8 ++-- lib/services/mostro_service.dart | 3 +- lib/shared/providers/app_init_provider.dart | 8 +--- 18 files changed, 53 insertions(+), 92 deletions(-) create mode 100644 android/app/src/main/res/drawable-hdpi/ic_stat_notifcations.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_stat_notifcations.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_stat_notifcations.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_stat_notifcations.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/ic_stat_notifcations.png delete mode 100644 lib/notifications/foreground_service_controller.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e740fe12..20814304 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -39,6 +39,10 @@ + + diff --git a/android/app/src/main/res/drawable-hdpi/ic_stat_notifcations.png b/android/app/src/main/res/drawable-hdpi/ic_stat_notifcations.png new file mode 100644 index 0000000000000000000000000000000000000000..0fc6e3932f53cf31acbe41ba833a91de50302152 GIT binary patch literal 1202 zcmV;j1Wo&iP)Px(Y)M2xR9HvNm}{tJQy9m8f49LG!$e80xkO`(q-4^3F+w3jE@R5b7bBBPVcd(v zM23b!j9ca)reTBzqg;w4*CCghndI(+d~hi|*8jEM(?0v`cdxyVFSF{L-gob{p7mer zd7l6Od0w%F4NVnKM9c%u0p<(;K zS$i30|j#s)3{9?5f_lJw6g zU=guXc4d9aOODC9`wXMy~_7xz!mws-Q-XhjwO=r zuI@DfP|l_e9o%q((;2wQf091QuDJj>QPRoz`;6qOp8*H)dB6d{^)>qkN%~^=tPbc| zU|_5;9+$K*BAhj=QYQWg+yQI^=1Q8M4|hNCSmLaez^j07cH5fm)sjw|1Sld71598| zfqpUYd^+j1z(U|kNy{VRft-mZOuN}Pv zm#;TLM#R}UwmO;wNds>U{zb&YDIm}7xcZu$IY*{=`U)@)IhY3{BDMnV0j|n!+@%tG zBe$pn`V!c`10c)9yMU=-Lz5;&#Gy{}Ava}W{2{xsGoU|fA~6@OeTBf=?Ptbc|E z>wq3ggtn-7jOGcu)m1-n+73xLt!D<+Y19F^KJJ?K)eWps)9Pv;5tn8u=>%qw?$xx< z>DrYTtD9_;v|sf>L^xY5mVHXyO_KH<02>-BNXMv?zMjZ&ZpDT<$Q9vnov-G-YmfDwx<--@G8pxGs8^~2 zmrF7y8IaZ5ty16N+T2Pp8BjLdnar&Al2%5<&Vbe3?N6W4UczQTE-T#FxvnXV1(L=h z!eZ+Ft zW~rplOE=hu-ksVrbJyXYWA-i>clY6+W5yECd{_c9eBTRDSEK)D-WCVwU*Z^fG~(F@ Qm;e9(07*qoM6N<$g3fM5nE(I) literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-mdpi/ic_stat_notifcations.png b/android/app/src/main/res/drawable-mdpi/ic_stat_notifcations.png new file mode 100644 index 0000000000000000000000000000000000000000..3b2affdd5b7278462d7a38968324239fdc2f70e3 GIT binary patch literal 787 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 JPDHLkV1kVdV_*OP literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_stat_notifcations.png b/android/app/src/main/res/drawable-xxxhdpi/ic_stat_notifcations.png new file mode 100644 index 0000000000000000000000000000000000000000..33f2a559f6f06f3937e2d06857d6484f4123a63b GIT binary patch literal 4414 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 Ef`G9 initialize(Settings settings); + Future initialize(); Future subscribe(Map filter); Future unsubscribe(String subscriptionId); void updateSettings(Settings settings); Future unsubscribeAll(); Future getActiveSubscriptionCount(); - void setForegroundStatus(bool isForeground); + Future setForegroundStatus(bool isForeground); Stream get eventsStream; bool get isRunning; } diff --git a/lib/background/background.dart b/lib/background/background.dart index df97f1ce..75fd40c1 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -7,6 +7,7 @@ import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/nostr_filter.dart'; import 'package:mostro_mobile/data/repositories/event_storage.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; +import 'package:mostro_mobile/notifications/notification_service.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; @@ -14,14 +15,13 @@ bool isAppForeground = false; @pragma('vm:entry-point') Future serviceMain(ServiceInstance service) async { - // If on Android, set up a permanent notification so the OS won't kill it. if (service is AndroidServiceInstance) { - service.setAsForegroundService(); + //service.setAsForegroundService(); const androidDetails = AndroidNotificationDetails( 'mostro_foreground', 'Mostro Foreground Service', - icon: '@mipmap/ic_launcher', + icon: '@drawable/ic_stat_notifcations', priority: Priority.high, importance: Importance.max, ); @@ -34,7 +34,7 @@ Future serviceMain(ServiceInstance service) async { await flutterLocalNotificationsPlugin.show( Config.notificationId, 'Mostro is running', - 'Connected to Nostr...', + 'Connected to Mostro serivce', notificationDetails, ); } @@ -76,9 +76,7 @@ Future serviceMain(ServiceInstance service) async { event, ); service.invoke('event', event.toMap()); - if (!isAppForeground) { - //await showLocalNotification(event); - } + await showLocalNotification(event); }); }); diff --git a/lib/background/background_service.dart b/lib/background/background_service.dart index 0bce37ac..1d999046 100644 --- a/lib/background/background_service.dart +++ b/lib/background/background_service.dart @@ -2,11 +2,12 @@ import 'dart:io'; import 'package:mostro_mobile/background/abstract_background_service.dart'; import 'package:mostro_mobile/background/desktop_background_service.dart'; import 'package:mostro_mobile/background/mobile_background_service.dart'; +import 'package:mostro_mobile/features/settings/settings.dart'; -BackgroundService createBackgroundService() { +BackgroundService createBackgroundService(Settings settings) { if (Platform.isAndroid || Platform.isIOS) { - return MobileBackgroundService(); + return MobileBackgroundService(settings); } else { return DesktopBackgroundService(); } diff --git a/lib/background/desktop_background_service.dart b/lib/background/desktop_background_service.dart index 84c89054..e260e904 100644 --- a/lib/background/desktop_background_service.dart +++ b/lib/background/desktop_background_service.dart @@ -3,7 +3,6 @@ import 'dart:isolate'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; -import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/data/models/nostr_filter.dart'; import 'package:mostro_mobile/data/repositories.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; @@ -19,25 +18,7 @@ class DesktopBackgroundService implements BackgroundService { late SendPort _sendPort; @override - Future initialize(Settings settings) async { - final token = ServicesBinding.rootIsolateToken!; - - final receivePort = ReceivePort(); - await Isolate.spawn(_isolateEntry, [receivePort.sendPort, token]); - - receivePort.listen((message) { - if (message is SendPort) { - _sendPort = message; - } - if (message is Map && message.containsKey('event')) { - final event = NostrEventExtensions.fromMap(message['event']); - _eventsController.add(event); - } - if (message is Map && message.containsKey('is-running')) { - _isRunning = message['is-running']; - } - }); - } + Future initialize() async {} static void _isolateEntry(List args) async { final isolateReceivePort = ReceivePort(); @@ -106,6 +87,8 @@ class DesktopBackgroundService implements BackgroundService { @override Future subscribe(Map filter) async { + if (!_isRunning) return false; + _sendPort.send( { 'command': 'create-subscription', @@ -116,7 +99,7 @@ class DesktopBackgroundService implements BackgroundService { } @override - void setForegroundStatus(bool isForeground) { + Future setForegroundStatus(bool isForeground) async { if (!_isRunning) return; _sendPort.send( { @@ -133,6 +116,8 @@ class DesktopBackgroundService implements BackgroundService { @override Future unsubscribe(String subscriptionId) async { + if (!_isRunning) return false; + if (!_subscriptions.containsKey(subscriptionId)) { return false; } @@ -172,7 +157,7 @@ class DesktopBackgroundService implements BackgroundService { @override Stream get eventsStream => _eventsController.stream; - + @override bool get isRunning => _isRunning; } diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index cd82ed5a..a8effe7b 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -3,11 +3,14 @@ import 'dart:async'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:mostro_mobile/background/background.dart'; -import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'abstract_background_service.dart'; class MobileBackgroundService implements BackgroundService { + Settings _settings; + + MobileBackgroundService(this._settings); + final service = FlutterBackgroundService(); final _eventsController = StreamController.broadcast(); @@ -15,26 +18,20 @@ class MobileBackgroundService implements BackgroundService { bool _isRunning = false; @override - Future initialize(Settings settings) async { + Future initialize() async { await service.configure( iosConfiguration: IosConfiguration( - autoStart: true, + autoStart: false, onForeground: serviceMain, onBackground: onIosBackground, ), androidConfiguration: AndroidConfiguration( - autoStart: true, + autoStart: false, onStart: serviceMain, isForegroundMode: false, autoStartOnBoot: true, ), ); - - service.on('event').listen((data) { - _eventsController.add( - NostrEventExtensions.fromMap(data!), - ); - }); } @override @@ -78,18 +75,25 @@ class MobileBackgroundService implements BackgroundService { } @override - void setForegroundStatus(bool isForeground) { - service.invoke( - 'app-foreground-status', - { - 'is-foreground': isForeground, - }, - ); + Future setForegroundStatus(bool isForeground) async { + if (isForeground) { + await _stopService(); + } else { + await _startService(); + } } Future _startService() async { await service.startService(); + while (!(await service.isRunning())) { + await Future.delayed(const Duration(milliseconds: 50)); + } + + service.invoke('settings-change', { + 'settings': _settings.toJson(), + }); + // Re-register all active subscriptions for (final entry in _subscriptions.entries) { service.invoke('create-subscription', { @@ -107,9 +111,7 @@ class MobileBackgroundService implements BackgroundService { @override void updateSettings(Settings settings) { - service.invoke('settings-change', { - 'settings': settings.toJson(), - }); + _settings = settings; } @override diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart index 1aaf9805..c9f4db26 100644 --- a/lib/features/order/notfiers/add_order_notifier.dart +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -59,7 +59,6 @@ class AddOrderNotifier extends AbstractMostroNotifier { ); } - // This method is called when the order is confirmed. Future confirmOrder(MostroMessage confirmedOrder) async { final orderNotifier = ref.watch( orderNotifierProvider(confirmedOrder.id!).notifier, diff --git a/lib/main.dart b/lib/main.dart index c94d209a..4d1bd287 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,8 +26,8 @@ Future main() async { await initializeNotifications(); - final backgroundService = createBackgroundService(); - await backgroundService.initialize(settings.settings); + final backgroundService = createBackgroundService(settings.settings); + await backgroundService.initialize(); runApp( ProviderScope( diff --git a/lib/notifications/foreground_service_controller.dart b/lib/notifications/foreground_service_controller.dart deleted file mode 100644 index aa21c587..00000000 --- a/lib/notifications/foreground_service_controller.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:logger/logger.dart'; - -class ForegroundServiceController { - static const _channel = MethodChannel('org.mostro.mobile/foreground_service'); - static final _logger = Logger(); - - static Future startService() async { - try { - await _channel.invokeMethod('startService'); - } on PlatformException catch (e) { - _logger.e("Failed to start service: ${e.message}"); - } - } - - static Future stopService() async { - try { - await _channel.invokeMethod('stopService'); - } on PlatformException catch (e) { - _logger.e("Failed to stop service: ${e.message}"); - } - } -} diff --git a/lib/notifications/notification_service.dart b/lib/notifications/notification_service.dart index 0a8b7c9c..a93d60f0 100644 --- a/lib/notifications/notification_service.dart +++ b/lib/notifications/notification_service.dart @@ -6,7 +6,7 @@ final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = Future initializeNotifications() async { const android = AndroidInitializationSettings( - '@mipmap/ic_launcher', + '@drawable/ic_stat_notifcations', ); const ios = DarwinInitializationSettings(); diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart index b77808b9..9e096dbd 100644 --- a/lib/services/lifecycle_manager.dart +++ b/lib/services/lifecycle_manager.dart @@ -40,7 +40,7 @@ class LifecycleManager extends WidgetsBindingObserver { // Stop background service final backgroundService = ref.read(backgroundServiceProvider); - backgroundService.setForegroundStatus(true); + await backgroundService.setForegroundStatus(true); await ref.read(nostrServiceProvider).syncBackgroundEvents(); @@ -56,7 +56,7 @@ class LifecycleManager extends WidgetsBindingObserver { // Transfer active subscriptions to background service final backgroundService = ref.read(backgroundServiceProvider); - backgroundService.setForegroundStatus(false); + await backgroundService.setForegroundStatus(false); for (final subscription in _activeSubscriptions) { await backgroundService.subscribe(subscription.toMap()); @@ -65,8 +65,8 @@ class LifecycleManager extends WidgetsBindingObserver { void addSubscription(NostrFilter filter) { _activeSubscriptions.add(filter); - final nostrService = ref.read(nostrServiceProvider); - nostrService.subscribeToEvents(filter); + // final nostrService = ref.read(nostrServiceProvider); + // nostrService.subscribeToEvents(filter); } void dispose() { diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index b73a84eb..50ad7095 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -42,7 +42,7 @@ Future init() async { void subscribe(Session session) { final filter = NostrFilter( kinds: [1059], - authors: [session.tradeKey.public], + p: [session.tradeKey.public], ); // Add subscription through lifecycle manager @@ -58,7 +58,6 @@ void subscribe(Session session) { event, ); - // Process event as you currently do: final decryptedEvent = await event.unWrap( session.tradeKey.private, ); diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 94205694..f1d9be6c 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -28,17 +28,13 @@ final appInitializerProvider = FutureProvider((ref) async { final mostroService = ref.read(mostroServiceProvider); await mostroService.init(); - final backgroundService = ref.read(backgroundServiceProvider); - backgroundService.updateSettings(ref.read(settingsProvider)); - ref.listen(settingsProvider, (previous, next) { sessionManager.updateSettings(next); mostroService.updateSettings(next); - backgroundService.updateSettings(next); + ref.read(backgroundServiceProvider).updateSettings(next); }); final mostroStorage = ref.read(mostroStorageProvider); - await mostroService.init(); for (final session in sessionManager.sessions) { if (session.orderId != null) { @@ -54,7 +50,7 @@ final appInitializerProvider = FutureProvider((ref) async { } if (session.peer != null) { - final chat = ref.watch( + final chat = ref.read( chatRoomsProvider(session.orderId!).notifier, ); chat.subscribe(); From b3fbba8e92b3850f78c6fe83e68d069011496031 Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 23 Apr 2025 07:43:39 +1000 Subject: [PATCH 102/149] Startup modifications --- .../abstract_background_service.dart | 4 +- lib/background/background.dart | 6 +- .../desktop_background_service.dart | 8 +- lib/background/mobile_background_service.dart | 9 +- lib/core/app.dart | 21 +--- lib/data/repositories/session_storage.dart | 6 +- lib/main.dart | 2 +- lib/services/mostro_service.dart | 111 ++++++++---------- .../providers/mostro_service_provider.dart | 11 -- 9 files changed, 67 insertions(+), 111 deletions(-) diff --git a/lib/background/abstract_background_service.dart b/lib/background/abstract_background_service.dart index e0468fd1..83617cee 100644 --- a/lib/background/abstract_background_service.dart +++ b/lib/background/abstract_background_service.dart @@ -1,14 +1,12 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; abstract class BackgroundService { - Future initialize(); + Future init(); Future subscribe(Map filter); Future unsubscribe(String subscriptionId); void updateSettings(Settings settings); Future unsubscribeAll(); Future getActiveSubscriptionCount(); Future setForegroundStatus(bool isForeground); - Stream get eventsStream; bool get isRunning; } diff --git a/lib/background/background.dart b/lib/background/background.dart index 75fd40c1..c972e424 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -42,7 +42,7 @@ Future serviceMain(ServiceInstance service) async { final Map> activeSubscriptions = {}; final nostrService = NostrService(); - final db = await openMostroDatabase('background.db'); + final db = await openMostroDatabase('mostro.db'); final backgroundStorage = EventStorage(db: db); service.on('app-foreground-status').listen((data) { @@ -89,8 +89,8 @@ Future serviceMain(ServiceInstance service) async { } }); - service.invoke('is-running', { - 'is-running': true, + service.on("stop").listen((event) { + service.stopSelf(); }); } diff --git a/lib/background/desktop_background_service.dart b/lib/background/desktop_background_service.dart index e260e904..22ccfe82 100644 --- a/lib/background/desktop_background_service.dart +++ b/lib/background/desktop_background_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:isolate'; -import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/nostr_filter.dart'; @@ -11,14 +10,12 @@ import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; import 'abstract_background_service.dart'; class DesktopBackgroundService implements BackgroundService { - final _eventsController = StreamController.broadcast(); - final _subscriptions = >{}; bool _isRunning = false; late SendPort _sendPort; @override - Future initialize() async {} + Future init() async {} static void _isolateEntry(List args) async { final isolateReceivePort = ReceivePort(); @@ -155,9 +152,6 @@ class DesktopBackgroundService implements BackgroundService { ); } - @override - Stream get eventsStream => _eventsController.stream; - @override bool get isRunning => _isRunning; } diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index a8effe7b..a57290e4 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:mostro_mobile/background/background.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; @@ -12,13 +11,12 @@ class MobileBackgroundService implements BackgroundService { MobileBackgroundService(this._settings); final service = FlutterBackgroundService(); - final _eventsController = StreamController.broadcast(); final _subscriptions = >{}; bool _isRunning = false; @override - Future initialize() async { + Future init() async { await service.configure( iosConfiguration: IosConfiguration( autoStart: false, @@ -105,7 +103,7 @@ class MobileBackgroundService implements BackgroundService { Future _stopService() async { // Use invoke pattern to request the service to stop itself - service.invoke('stopService'); + service.invoke('stop'); _isRunning = false; } @@ -114,9 +112,6 @@ class MobileBackgroundService implements BackgroundService { _settings = settings; } - @override - Stream get eventsStream => _eventsController.stream; - @override bool get isRunning => _isRunning; } diff --git a/lib/core/app.dart b/lib/core/app.dart index 4818dfb8..12e44d4b 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -7,8 +7,8 @@ import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/features/auth/providers/auth_notifier_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/features/auth/notifiers/auth_state.dart'; +import 'package:mostro_mobile/services/lifecycle_manager.dart'; import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; -import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; class MostroApp extends ConsumerStatefulWidget { const MostroApp({super.key}); @@ -20,26 +20,11 @@ class MostroApp extends ConsumerStatefulWidget { ConsumerState createState() => _MostroAppState(); } -class _MostroAppState extends ConsumerState - with WidgetsBindingObserver { +class _MostroAppState extends ConsumerState { @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - final isForeground = state == AppLifecycleState.resumed; - final backgroundService = ref.read(backgroundServiceProvider); - backgroundService.setForegroundStatus(isForeground); - + ref.read(lifecycleManagerProvider); } @override diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index 59a34dac..e131f550 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -62,9 +62,11 @@ class SessionStorage extends BaseStorage { Future deleteSession(String sessionId) => deleteItem(sessionId); Future> deleteExpiredSessions( - int sessionExpirationHours, int maxBatchSize) { + int sessionExpirationHours, + int maxBatchSize, + ) async { final now = DateTime.now(); - return deleteWhere((session) { + return await deleteWhere((session) { final startTime = session.startTime; return now.difference(startTime).inHours >= sessionExpirationHours; }, maxBatchSize: maxBatchSize); diff --git a/lib/main.dart b/lib/main.dart index 4d1bd287..f2a4de55 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,7 +27,7 @@ Future main() async { await initializeNotifications(); final backgroundService = createBackgroundService(settings.settings); - await backgroundService.initialize(); + await backgroundService.init(); runApp( ProviderScope( diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 50ad7095..be4af436 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,88 +1,81 @@ import 'dart:convert'; import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/background/abstract_background_service.dart'; import 'package:mostro_mobile/data/models.dart'; -import 'package:mostro_mobile/data/repositories.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/event_bus.dart'; import 'package:mostro_mobile/services/lifecycle_manager.dart'; import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; class MostroService { final Ref ref; final SessionNotifier _sessionNotifier; - final EventStorage _eventStorage; - final MostroStorage _messageStorage; - final EventBus _bus; Settings _settings; - final BackgroundService backgroundService; - MostroService( this._sessionNotifier, - this._eventStorage, - this._bus, - this._messageStorage, this.ref, - this.backgroundService, - ) : _settings = ref.read(settingsProvider); + ) : _settings = ref.read(settingsProvider).copyWith(); -Future init() async { - final sessions = _sessionNotifier.sessions; - for (final session in sessions) { - subscribe(session); + Future init() async { + final sessions = _sessionNotifier.sessions; + for (final session in sessions) { + subscribe(session); + } } -} - -void subscribe(Session session) { - final filter = NostrFilter( - kinds: [1059], - p: [session.tradeKey.public], - ); - - // Add subscription through lifecycle manager - ref.read(lifecycleManagerProvider).addSubscription(filter); - - final nostrService = ref.read(nostrServiceProvider); - - nostrService.subscribeToEvents(filter).listen((event) async { - - if (await _eventStorage.hasItem(event.id!)) return; - await _eventStorage.putItem( - event.id!, - event, - ); - final decryptedEvent = await event.unWrap( - session.tradeKey.private, + void subscribe(Session session) { + final filter = NostrFilter( + kinds: [1059], + p: [session.tradeKey.public], ); - if (decryptedEvent.content == null) return; - - final result = jsonDecode(decryptedEvent.content!); - if (result is! List) return; - final msg = MostroMessage.fromJson(result[0]); - if (msg.id != null) { - if (await _messageStorage.hasMessage(msg)) return; - ref.read(orderActionNotifierProvider(msg.id!).notifier).set(msg.action); - } - if (msg.action == Action.canceled) { - await _messageStorage.deleteAllMessagesById(session.orderId!); - await _sessionNotifier.deleteSession(session.orderId!); - return; - } - await _messageStorage.addMessage(msg); - if (session.orderId == null && msg.id != null) { - session.orderId = msg.id; - await _sessionNotifier.saveSession(session); - } - _bus.emit(msg); - }); + ref.read(lifecycleManagerProvider).addSubscription(filter); + + final nostrService = ref.read(nostrServiceProvider); + + nostrService.subscribeToEvents(filter).listen((event) async { + final eventStore = ref.read(eventStorageProvider); + + if (await eventStore.hasItem(event.id!)) return; + await eventStore.putItem( + event.id!, + event, + ); + + final decryptedEvent = await event.unWrap( + session.tradeKey.private, + ); + if (decryptedEvent.content == null) return; + + final result = jsonDecode(decryptedEvent.content!); + if (result is! List) return; + + final msg = MostroMessage.fromJson(result[0]); + final messageStorage = ref.read(mostroStorageProvider); + + if (msg.id != null) { + if (await messageStorage.hasMessage(msg)) return; + ref.read(orderActionNotifierProvider(msg.id!).notifier).set(msg.action); + } + if (msg.action == Action.canceled) { + await messageStorage.deleteAllMessagesById(session.orderId!); + await _sessionNotifier.deleteSession(session.orderId!); + return; + } + await messageStorage.addMessage(msg); + if (session.orderId == null && msg.id != null) { + session.orderId = msg.id; + await _sessionNotifier.saveSession(session); + } + ref.read(eventBusProvider).emit(msg); + }); } Session? getSessionByOrderId(String orderId) { diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index 5d107d46..f2a2108b 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -1,10 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/repositories/event_storage.dart'; -import 'package:mostro_mobile/services/event_bus.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; -import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; -import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'mostro_service_provider.g.dart'; @@ -18,17 +15,9 @@ EventStorage eventStorage(Ref ref) { @riverpod MostroService mostroService(Ref ref) { final sessionStorage = ref.read(sessionNotifierProvider.notifier); - final eventStore = ref.read(eventStorageProvider); - final eventBus = ref.read(eventBusProvider); - final mostroDatabase = ref.read(mostroStorageProvider); - final backgroundService = ref.read(backgroundServiceProvider); final mostroService = MostroService( sessionStorage, - eventStore, - eventBus, - mostroDatabase, ref, - backgroundService, ); return mostroService; } From 4f4f63e6b7785345548c1bc3377c98a6d9c96745 Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 23 Apr 2025 14:17:44 +1000 Subject: [PATCH 103/149] Refactored background service --- .../abstract_background_service.dart | 7 ++-- lib/background/background.dart | 36 +++++++++---------- .../desktop_background_service.dart | 21 ++++++----- lib/background/mobile_background_service.dart | 19 +++------- .../repositories/open_orders_repository.dart | 27 +++++++++----- .../chat/notifiers/chat_room_notifier.dart | 5 ++- lib/services/lifecycle_manager.dart | 27 ++++++-------- lib/services/mostro_service.dart | 11 ++++-- lib/services/nostr_service.dart | 32 +++-------------- lib/shared/providers/app_init_provider.dart | 1 - .../providers/mostro_service_provider.dart | 4 +-- 11 files changed, 85 insertions(+), 105 deletions(-) diff --git a/lib/background/abstract_background_service.dart b/lib/background/abstract_background_service.dart index 83617cee..2dc114af 100644 --- a/lib/background/abstract_background_service.dart +++ b/lib/background/abstract_background_service.dart @@ -1,12 +1,13 @@ +import 'package:dart_nostr/dart_nostr.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; abstract class BackgroundService { Future init(); - Future subscribe(Map filter); - Future unsubscribe(String subscriptionId); + void subscribe(List filters); void updateSettings(Settings settings); + Future setForegroundStatus(bool isForeground); + Future unsubscribe(String subscriptionId); Future unsubscribeAll(); Future getActiveSubscriptionCount(); - Future setForegroundStatus(bool isForeground); bool get isRunning; } diff --git a/lib/background/background.dart b/lib/background/background.dart index c972e424..e7990fd8 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:ui'; +import 'package:dart_nostr/nostr/model/request/request.dart'; import 'package:flutter/material.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -41,41 +42,35 @@ Future serviceMain(ServiceInstance service) async { final Map> activeSubscriptions = {}; final nostrService = NostrService(); - final db = await openMostroDatabase('mostro.db'); - final backgroundStorage = EventStorage(db: db); + final eventStore = EventStorage(db: db); service.on('app-foreground-status').listen((data) { isAppForeground = data?['is-foreground'] ?? isAppForeground; }); - service.on('settings-change').listen((data) { + service.on('start').listen((data) async { if (data == null) return; final settingsMap = data['settings']; if (settingsMap == null) return; - nostrService.init( - Settings.fromJson( - settingsMap, - ), - ); + final settings = Settings.fromJson(settingsMap); + await nostrService.init(settings); }); service.on('create-subscription').listen((data) { - if (data == null || data['filter'] == null) return; + if (data == null || data['filters'] == null) return; - final filter = NostrFilterX.fromJsonSafe( - data['filter'], - ); + final filterMap = data['filters'] as List>; + + final filters = filterMap.map((e) => NostrFilterX.fromJsonSafe(e)).toList(); + + final request = NostrRequest(filters: filters); - final subscription = nostrService.subscribeToEvents(filter); + final subscription = nostrService.subscribeToEvents(request); subscription.listen((event) async { - await backgroundStorage.putItem( - event.id!, - event, - ); - service.invoke('event', event.toMap()); + if (await eventStore.hasItem(event.id!)) return; await showLocalNotification(event); }); }); @@ -86,10 +81,13 @@ Future serviceMain(ServiceInstance service) async { final id = event['id'] as String?; if (id != null && activeSubscriptions.containsKey(id)) { activeSubscriptions.remove(id); + nostrService.unsubscribe(id); } }); - service.on("stop").listen((event) { + service.on("stop").listen((event) async { + nostrService.disconnectFromRelays(); + await db.close(); service.stopSelf(); }); } diff --git a/lib/background/desktop_background_service.dart b/lib/background/desktop_background_service.dart index 22ccfe82..40c34ab2 100644 --- a/lib/background/desktop_background_service.dart +++ b/lib/background/desktop_background_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:isolate'; +import 'package:dart_nostr/dart_nostr.dart'; import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/nostr_filter.dart'; @@ -17,7 +18,7 @@ class DesktopBackgroundService implements BackgroundService { @override Future init() async {} - static void _isolateEntry(List args) async { + static void isolateEntry(List args) async { final isolateReceivePort = ReceivePort(); final mainSendPort = args[0] as SendPort; final token = args[1] as RootIsolateToken; @@ -51,13 +52,18 @@ class DesktopBackgroundService implements BackgroundService { ); break; case 'create-subscription': - if (message['filter'] == null) return; + if (message['filters'] == null) return; - final filter = NostrFilterX.fromJsonSafe( - message['filter'], + final filterMap = message['filters'] as List>; + + final filters = + filterMap.map((e) => NostrFilterX.fromJsonSafe(e)).toList(); + + final request = NostrRequest( + filters: filters, ); - final subscription = nostrService.subscribeToEvents(filter); + final subscription = nostrService.subscribeToEvents(request); subscription.listen((event) async { await backgroundStorage.putItem( event.id!, @@ -83,8 +89,8 @@ class DesktopBackgroundService implements BackgroundService { } @override - Future subscribe(Map filter) async { - if (!_isRunning) return false; + void subscribe(List filter) { + if (!_isRunning) return; _sendPort.send( { @@ -92,7 +98,6 @@ class DesktopBackgroundService implements BackgroundService { 'filter': filter, }, ); - return true; } @override diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index a57290e4..df938b91 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:mostro_mobile/background/background.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; @@ -33,12 +34,10 @@ class MobileBackgroundService implements BackgroundService { } @override - Future subscribe(Map filter) async { + void subscribe(List filters) { service.invoke('create-subscription', { - 'filter': filter, + 'filters': filters.map((f) => f.toMap()).toList(), }); - - return true; } @override @@ -52,7 +51,6 @@ class MobileBackgroundService implements BackgroundService { 'id': subscriptionId, }); - // If no more subscriptions, stop the service if (_subscriptions.isEmpty && _isRunning) { await _stopService(); } @@ -88,21 +86,12 @@ class MobileBackgroundService implements BackgroundService { await Future.delayed(const Duration(milliseconds: 50)); } - service.invoke('settings-change', { + service.invoke('start', { 'settings': _settings.toJson(), }); - - // Re-register all active subscriptions - for (final entry in _subscriptions.entries) { - service.invoke('create-subscription', { - 'filter': entry.value, - 'id': entry.key, - }); - } } Future _stopService() async { - // Use invoke pattern to request the service to stop itself service.invoke('stop'); _isRunning = false; } diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index e120b9eb..05f04811 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:dart_nostr/nostr/model/request/filter.dart'; +import 'package:dart_nostr/nostr/model/request/request.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'; @@ -39,7 +40,11 @@ class OpenOrdersRepository implements OrderRepository { since: filterTime, ); - _subscription = _nostrService.subscribeToEvents(filter).listen((event) { + final request = NostrRequest( + filters: [filter], + ); + + _subscription = _nostrService.subscribeToEvents(request).listen((event) { if (event.type == 'order') { _events[event.orderId!] = event; _eventStreamController.add(_events.values.toList()); @@ -69,26 +74,30 @@ class OpenOrdersRepository implements OrderRepository { @override Future addOrder(NostrEvent order) { - // TODO: implement addOrder - throw UnimplementedError(); + _events[order.id!] = order; + _eventStreamController.add(_events.values.toList()); + return Future.value(); } @override Future deleteOrder(String orderId) { - // TODO: implement deleteOrder - throw UnimplementedError(); + _events.remove(orderId); + _eventStreamController.add(_events.values.toList()); + return Future.value(); } @override Future> getAllOrders() { - // TODO: implement getAllOrders - throw UnimplementedError(); + return Future.value(_events.values.toList()); } @override Future updateOrder(NostrEvent order) { - // TODO: implement updateOrder - throw UnimplementedError(); + if (_events.containsKey(order.id)) { + _events[order.id!] = order; + _eventStreamController.add(_events.values.toList()); + } + return Future.value(); } void updateSettings(Settings settings) { diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index 232687e3..07e86449 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -26,8 +26,11 @@ class ChatRoomNotifier extends StateNotifier { kinds: [1059], p: [session!.sharedKey!.public], ); + final request = NostrRequest( + filters: [filter], + ); subscription = - ref.read(nostrServiceProvider).subscribeToEvents(filter).listen( + ref.read(nostrServiceProvider).subscribeToEvents(request).listen( (event) async { try { final chat = await event.mostroUnWrap(session.sharedKey!); diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart index 9e096dbd..1620bfb0 100644 --- a/lib/services/lifecycle_manager.dart +++ b/lib/services/lifecycle_manager.dart @@ -1,8 +1,9 @@ import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; -import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; class LifecycleManager extends WidgetsBindingObserver { final Ref ref; @@ -37,36 +38,28 @@ class LifecycleManager extends WidgetsBindingObserver { Future _switchToForeground() async { _isInBackground = false; - // Stop background service final backgroundService = ref.read(backgroundServiceProvider); await backgroundService.setForegroundStatus(true); - - await ref.read(nostrServiceProvider).syncBackgroundEvents(); - - // Re-establish direct subscriptions - final nostrService = ref.read(nostrServiceProvider); - for (final subscription in _activeSubscriptions) { - nostrService.subscribeToEvents(subscription); - } + // Reinitialize the mostro service + ref.read(mostroServiceProvider).init(); + // Reinitialize chat rooms + final chatRooms = ref.read(chatRoomsNotifierProvider.notifier); + await chatRooms.loadChats(); + // Clear active subscriptions + _activeSubscriptions.clear(); } Future _switchToBackground() async { _isInBackground = true; - // Transfer active subscriptions to background service final backgroundService = ref.read(backgroundServiceProvider); await backgroundService.setForegroundStatus(false); - - for (final subscription in _activeSubscriptions) { - await backgroundService.subscribe(subscription.toMap()); - } + backgroundService.subscribe(_activeSubscriptions); } void addSubscription(NostrFilter filter) { _activeSubscriptions.add(filter); - // final nostrService = ref.read(nostrServiceProvider); - // nostrService.subscribeToEvents(filter); } void dispose() { diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index be4af436..2f74a042 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:dart_nostr/nostr/model/export.dart'; import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models.dart'; @@ -21,9 +22,11 @@ class MostroService { MostroService( this._sessionNotifier, this.ref, - ) : _settings = ref.read(settingsProvider).copyWith(); + ) : _settings = ref.read(settingsProvider).copyWith() { + init(); + } - Future init() async { + void init() { final sessions = _sessionNotifier.sessions; for (final session in sessions) { subscribe(session); @@ -36,11 +39,13 @@ class MostroService { p: [session.tradeKey.public], ); + final request = NostrRequest(filters: [filter]); + ref.read(lifecycleManagerProvider).addSubscription(filter); final nostrService = ref.read(nostrServiceProvider); - nostrService.subscribeToEvents(filter).listen((event) async { + nostrService.subscribeToEvents(request).listen((event) async { final eventStore = ref.read(eventStorageProvider); if (await eventStore.hasItem(event.id!)) return; diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index 8d7874eb..a26f4ab9 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -3,9 +3,7 @@ import 'package:dart_nostr/dart_nostr.dart'; import 'package:dart_nostr/nostr/model/relay_informations.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/config.dart'; -import 'package:mostro_mobile/data/repositories/event_storage.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; -import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; class NostrService { @@ -87,12 +85,11 @@ class NostrService { ); } - Stream subscribeToEvents(NostrFilter filter) { + Stream subscribeToEvents(NostrRequest request) { if (!_isInitialized) { throw Exception('Nostr is not initialized. Call init() first.'); } - final request = NostrRequest(filters: [filter]); final subscription = _nostr.services.relays.startEventsSubscription(request: request); @@ -176,30 +173,11 @@ class NostrService { ); } -// Add method to sync background events - Future syncBackgroundEvents() async { - // Get the background database - final backgroundDb = await openMostroDatabase('background.db'); - final backgroundStorage = EventStorage(db: backgroundDb); - - // Get all events from background database - final events = await backgroundStorage.getAllItems(); - - // Process each event - for (final event in events) { - // Process event through your regular pipeline - // This might involve decrypting, parsing, and emitting to event bus - await processEvent(event); + void unsubscribe(String id) { + if (!_isInitialized) { + throw Exception('Nostr is not initialized. Call init() first.'); } - // Optionally clear background database after syncing - // await backgroundStorage.deleteAllItems(); - } - -// Add method to process events (similar to what was in MostroService) - Future processEvent(NostrEvent event) async { - // Your event processing logic here - // This would be similar to what was in MostroService._handleIncomingEvent - // but without the duplicate checking + _nostr.services.relays.closeEventsSubscription(id); } } diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index f1d9be6c..8ccd64cb 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -26,7 +26,6 @@ final appInitializerProvider = FutureProvider((ref) async { await sessionManager.init(); final mostroService = ref.read(mostroServiceProvider); - await mostroService.init(); ref.listen(settingsProvider, (previous, next) { sessionManager.updateSettings(next); diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index f2a2108b..8b5a89be 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -14,9 +14,9 @@ EventStorage eventStorage(Ref ref) { @riverpod MostroService mostroService(Ref ref) { - final sessionStorage = ref.read(sessionNotifierProvider.notifier); + final sessionNotifier = ref.read(sessionNotifierProvider.notifier); final mostroService = MostroService( - sessionStorage, + sessionNotifier, ref, ); return mostroService; From d8b56db3cd5b648a0964c721523849447da54bc8 Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 23 Apr 2025 14:31:06 +1000 Subject: [PATCH 104/149] Replace route builders with pageBuilder to add fade transitions between screens --- lib/core/app_routes.dart | 136 +++++++++++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 27 deletions(-) diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index 5bebe4a1..f9dee2b7 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -36,65 +36,118 @@ final goRouter = GoRouter( routes: [ GoRoute( path: '/', - builder: (context, state) => const HomeScreen(), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: const HomeScreen(), + ), ), GoRoute( path: '/welcome', - builder: (context, state) => const WelcomeScreen(), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: const WelcomeScreen(), + ), ), GoRoute( path: '/order_book', - builder: (context, state) => const TradesScreen(), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: const TradesScreen(), + ), ), GoRoute( path: '/trade_detail/:orderId', - builder: (context, state) => TradeDetailScreen( - orderId: state.pathParameters['orderId']!, - ), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: TradeDetailScreen( + orderId: state.pathParameters['orderId']!, + )), ), GoRoute( path: '/chat_list', - builder: (context, state) => const ChatRoomsScreen(), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: const ChatRoomsScreen(), + ), ), GoRoute( path: '/chat_room/:orderId', - builder: (context, state) => ChatRoomScreen( - orderId: state.pathParameters['orderId']!, - ), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: ChatRoomScreen( + orderId: state.pathParameters['orderId']!, + )), ), GoRoute( path: '/register', - builder: (context, state) => const RegisterScreen(), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: const RegisterScreen(), + ), ), GoRoute( path: '/relays', - builder: (context, state) => const RelaysScreen(), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: const RelaysScreen(), + ), ), GoRoute( path: '/key_management', - builder: (context, state) => const KeyManagementScreen(), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: const KeyManagementScreen(), + ), ), GoRoute( path: '/settings', - builder: (context, state) => const SettingsScreen(), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: const SettingsScreen(), + ), ), GoRoute( path: '/about', - builder: (context, state) => const AboutScreen(), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: const AboutScreen(), + ), ), GoRoute( path: '/walkthrough', - builder: (context, state) => WalkthroughScreen(), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: WalkthroughScreen(), + ), ), GoRoute( path: '/add_order', - builder: (context, state) => AddOrderScreen(), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: AddOrderScreen(), + ), ), GoRoute( path: '/rate_user/:orderId', - builder: (context, state) => RateCounterpartScreen( - orderId: state.pathParameters['orderId']!, - ), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: RateCounterpartScreen( + orderId: state.pathParameters['orderId']!, + )), ), GoRoute( path: '/take_sell/:orderId', @@ -112,19 +165,30 @@ final goRouter = GoRouter( ), GoRoute( path: '/order_confirmed/:orderId', - builder: (context, state) => OrderConfirmationScreen( - orderId: state.pathParameters['orderId']!, - ), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: OrderConfirmationScreen( + orderId: state.pathParameters['orderId']!, + )), ), GoRoute( path: '/pay_invoice/:orderId', - builder: (context, state) => PayLightningInvoiceScreen( - orderId: state.pathParameters['orderId']!), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: PayLightningInvoiceScreen( + orderId: state.pathParameters['orderId']!, + )), ), GoRoute( path: '/add_invoice/:orderId', - builder: (context, state) => AddLightningInvoiceScreen( - orderId: state.pathParameters['orderId']!), + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: AddLightningInvoiceScreen( + orderId: state.pathParameters['orderId']!, + )), ), ], ), @@ -134,3 +198,21 @@ final goRouter = GoRouter( body: Center(child: Text(state.error.toString())), ), ); + +CustomTransitionPage buildPageWithDefaultTransition({ + required BuildContext context, + required GoRouterState state, + required Widget child, +}) { + return CustomTransitionPage( + key: state.pageKey, + child: child, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurveTween(curve: Curves.easeInOut).animate(animation), + child: child, + ); + }, + transitionDuration: const Duration(milliseconds: 150), + ); +} From d2e574d128d7e07ecde866afcd2d5b59af1cd5fe Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 23 Apr 2025 17:43:35 +1000 Subject: [PATCH 105/149] Added isRunning callback --- lib/background/background.dart | 13 ++++++----- lib/background/mobile_background_service.dart | 5 +++++ lib/data/models/nostr_filter.dart | 22 ++++++++++++++++--- lib/services/lifecycle_manager.dart | 4 ++-- lib/services/nostr_service.dart | 18 +++++++++++++-- 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/lib/background/background.dart b/lib/background/background.dart index e7990fd8..e61969c9 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:ui'; -import 'package:dart_nostr/nostr/model/request/request.dart'; import 'package:flutter/material.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -18,7 +17,7 @@ bool isAppForeground = false; Future serviceMain(ServiceInstance service) async { // If on Android, set up a permanent notification so the OS won't kill it. if (service is AndroidServiceInstance) { - //service.setAsForegroundService(); + service.setAsForegroundService(); const androidDetails = AndroidNotificationDetails( 'mostro_foreground', 'Mostro Foreground Service', @@ -62,11 +61,11 @@ Future serviceMain(ServiceInstance service) async { service.on('create-subscription').listen((data) { if (data == null || data['filters'] == null) return; - final filterMap = data['filters'] as List>; + final filterMap = data['filters']; - final filters = filterMap.map((e) => NostrFilterX.fromJsonSafe(e)).toList(); + final filters = filterMap.toList(); - final request = NostrRequest(filters: filters); + final request = NostrRequestX.fromJson(filters); final subscription = nostrService.subscribeToEvents(request); subscription.listen((event) async { @@ -90,6 +89,10 @@ Future serviceMain(ServiceInstance service) async { await db.close(); service.stopSelf(); }); + + service.invoke('on-start', { + 'isRunning': true, + }); } @pragma('vm:entry-point') diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index df938b91..f7f90b89 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -31,10 +31,15 @@ class MobileBackgroundService implements BackgroundService { autoStartOnBoot: true, ), ); + + service.on('on-start').listen((data) { + _isRunning = true; + }); } @override void subscribe(List filters) { + service.invoke('create-subscription', { 'filters': filters.map((f) => f.toMap()).toList(), }); diff --git a/lib/data/models/nostr_filter.dart b/lib/data/models/nostr_filter.dart index 16f98101..e1165912 100644 --- a/lib/data/models/nostr_filter.dart +++ b/lib/data/models/nostr_filter.dart @@ -1,7 +1,21 @@ import 'package:dart_nostr/dart_nostr.dart'; -extension NostrFilterX on NostrFilter { +extension NostrRequestX on NostrRequest { + static NostrRequest fromJson(List json) { + + final filters = json + .map( + (e) => NostrFilterX.fromJsonSafe(e), + ) + .toList(); + return NostrRequest( + filters: filters, + ); + } +} + +extension NostrFilterX on NostrFilter { static NostrFilter fromJsonSafe(Map json) { final additional = {}; @@ -32,10 +46,12 @@ extension NostrFilterX on NostrFilter { t: castList(json['#t']), a: castList(json['#a']), since: safeCast(json['since']) != null - ? DateTime.fromMillisecondsSinceEpoch(safeCast(json['since'])! * 1000) + ? DateTime.fromMillisecondsSinceEpoch( + safeCast(json['since'])! * 1000) : null, until: safeCast(json['until']) != null - ? DateTime.fromMillisecondsSinceEpoch(safeCast(json['until'])! * 1000) + ? DateTime.fromMillisecondsSinceEpoch( + safeCast(json['until'])! * 1000) : null, limit: safeCast(json['limit']), search: safeCast(json['search']), diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart index 1620bfb0..fae814fa 100644 --- a/lib/services/lifecycle_manager.dart +++ b/lib/services/lifecycle_manager.dart @@ -38,6 +38,8 @@ class LifecycleManager extends WidgetsBindingObserver { Future _switchToForeground() async { _isInBackground = false; + // Clear active subscriptions + _activeSubscriptions.clear(); // Stop background service final backgroundService = ref.read(backgroundServiceProvider); await backgroundService.setForegroundStatus(true); @@ -46,8 +48,6 @@ class LifecycleManager extends WidgetsBindingObserver { // Reinitialize chat rooms final chatRooms = ref.read(chatRoomsNotifierProvider.notifier); await chatRooms.loadChats(); - // Clear active subscriptions - _activeSubscriptions.clear(); } Future _switchToBackground() async { diff --git a/lib/services/nostr_service.dart b/lib/services/nostr_service.dart index a26f4ab9..5dd88796 100644 --- a/lib/services/nostr_service.dart +++ b/lib/services/nostr_service.dart @@ -1,5 +1,7 @@ import 'package:collection/collection.dart'; import 'package:dart_nostr/dart_nostr.dart'; +import 'package:dart_nostr/nostr/model/ease.dart'; +import 'package:dart_nostr/nostr/model/ok.dart'; import 'package:dart_nostr/nostr/model/relay_informations.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/config.dart'; @@ -24,8 +26,20 @@ class NostrService { shouldReconnectToRelayOnNotice: true, retryOnClose: true, retryOnError: true, - onRelayListening: (relay, url, channel) { - _logger.i('Connected to relay: $relay'); + onRelayListening: (relayUrl, receivedData, channel) { + if (receivedData is NostrEvent) { + _logger.i('Event from $relayUrl: ${receivedData.content}'); + } else if (receivedData is NostrNotice) { + _logger.i('Notice from $relayUrl: ${receivedData.message}'); + } else if (receivedData is NostrEventOkCommand) { + _logger.i( + 'OK from $relayUrl: ${receivedData.eventId} (accepted: ${receivedData.isEventAccepted})'); + } else if (receivedData is NostrRequestEoseCommand) { + _logger.i( + 'EOSE from $relayUrl for subscription: ${receivedData.subscriptionId}'); + } else if (receivedData is NostrCountResponse) { + _logger.i('Count from $relayUrl: ${receivedData.count}'); + } }, onRelayConnectionError: (relay, error, channel) { _logger.w('Failed to connect to relay $relay: $error'); From c4c8210e422f85781b6aadcca8d683b5bf012b58 Mon Sep 17 00:00:00 2001 From: Biz Date: Fri, 25 Apr 2025 09:47:40 +1000 Subject: [PATCH 106/149] renamed notification icon --- android/app/src/main/AndroidManifest.xml | 2 +- ...tifcations.png => ic_bg_service_small.png} | Bin ...tifcations.png => ic_bg_service_small.png} | Bin ...tifcations.png => ic_bg_service_small.png} | Bin ...tifcations.png => ic_bg_service_small.png} | Bin ...tifcations.png => ic_bg_service_small.png} | Bin lib/background/background.dart | 2 +- lib/background/mobile_background_service.dart | 18 ++++++++++-------- lib/notifications/notification_service.dart | 2 +- 9 files changed, 13 insertions(+), 11 deletions(-) rename android/app/src/main/res/drawable-hdpi/{ic_stat_notifcations.png => ic_bg_service_small.png} (100%) rename android/app/src/main/res/drawable-mdpi/{ic_stat_notifcations.png => ic_bg_service_small.png} (100%) rename android/app/src/main/res/drawable-xhdpi/{ic_stat_notifcations.png => ic_bg_service_small.png} (100%) rename android/app/src/main/res/drawable-xxhdpi/{ic_stat_notifcations.png => ic_bg_service_small.png} (100%) rename android/app/src/main/res/drawable-xxxhdpi/{ic_stat_notifcations.png => ic_bg_service_small.png} (100%) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 20814304..4b077d8e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -41,7 +41,7 @@ android:value="2" /> + android:resource="@drawable/ic_bg_service_small" /> diff --git a/android/app/src/main/res/drawable-hdpi/ic_stat_notifcations.png b/android/app/src/main/res/drawable-hdpi/ic_bg_service_small.png similarity index 100% rename from android/app/src/main/res/drawable-hdpi/ic_stat_notifcations.png rename to android/app/src/main/res/drawable-hdpi/ic_bg_service_small.png diff --git a/android/app/src/main/res/drawable-mdpi/ic_stat_notifcations.png b/android/app/src/main/res/drawable-mdpi/ic_bg_service_small.png similarity index 100% rename from android/app/src/main/res/drawable-mdpi/ic_stat_notifcations.png rename to android/app/src/main/res/drawable-mdpi/ic_bg_service_small.png diff --git a/android/app/src/main/res/drawable-xhdpi/ic_stat_notifcations.png b/android/app/src/main/res/drawable-xhdpi/ic_bg_service_small.png similarity index 100% rename from android/app/src/main/res/drawable-xhdpi/ic_stat_notifcations.png rename to android/app/src/main/res/drawable-xhdpi/ic_bg_service_small.png diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_stat_notifcations.png b/android/app/src/main/res/drawable-xxhdpi/ic_bg_service_small.png similarity index 100% rename from android/app/src/main/res/drawable-xxhdpi/ic_stat_notifcations.png rename to android/app/src/main/res/drawable-xxhdpi/ic_bg_service_small.png diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_stat_notifcations.png b/android/app/src/main/res/drawable-xxxhdpi/ic_bg_service_small.png similarity index 100% rename from android/app/src/main/res/drawable-xxxhdpi/ic_stat_notifcations.png rename to android/app/src/main/res/drawable-xxxhdpi/ic_bg_service_small.png diff --git a/lib/background/background.dart b/lib/background/background.dart index e61969c9..2816f844 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -21,7 +21,7 @@ Future serviceMain(ServiceInstance service) async { const androidDetails = AndroidNotificationDetails( 'mostro_foreground', 'Mostro Foreground Service', - icon: '@drawable/ic_stat_notifcations', + icon: '@drawable/ic_bg_service_small', priority: Priority.high, importance: Importance.max, ); diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index f7f90b89..6e70e964 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -20,26 +20,28 @@ class MobileBackgroundService implements BackgroundService { Future init() async { await service.configure( iosConfiguration: IosConfiguration( - autoStart: false, + autoStart: true, onForeground: serviceMain, onBackground: onIosBackground, ), androidConfiguration: AndroidConfiguration( - autoStart: false, - onStart: serviceMain, - isForegroundMode: false, - autoStartOnBoot: true, - ), + autoStart: true, + onStart: serviceMain, + isForegroundMode: false, + autoStartOnBoot: true, + initialNotificationContent: "Mostro P2P", + foregroundServiceTypes: [ + AndroidForegroundType.dataSync, + ]), ); service.on('on-start').listen((data) { - _isRunning = true; + _isRunning = true; }); } @override void subscribe(List filters) { - service.invoke('create-subscription', { 'filters': filters.map((f) => f.toMap()).toList(), }); diff --git a/lib/notifications/notification_service.dart b/lib/notifications/notification_service.dart index a93d60f0..01e934c6 100644 --- a/lib/notifications/notification_service.dart +++ b/lib/notifications/notification_service.dart @@ -6,7 +6,7 @@ final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = Future initializeNotifications() async { const android = AndroidInitializationSettings( - '@drawable/ic_stat_notifcations', + '@drawable/ic_bg_service_small', ); const ios = DarwinInitializationSettings(); From 0cca048178177abdfea3522ca18b8658c4f1021f Mon Sep 17 00:00:00 2001 From: Biz Date: Fri, 25 Apr 2025 10:10:52 +1000 Subject: [PATCH 107/149] Add background service capabilities and persistent notifications for iOS/Android --- ios/Runner/Info.plist | 10 ++++++++-- lib/background/background.dart | 6 +++++- lib/background/mobile_background_service.dart | 10 +++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 83c757fd..33bd7a65 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Mostro P2P + Mostro P2P CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Mostro P2P + Mostro P2P CFBundlePackageType APPL CFBundleShortVersionString @@ -47,5 +47,11 @@ NSCameraUsageDescription This app needs camera access to scan QR codes + UIBackgroundModes + + fetch + processing + remote-notification + \ No newline at end of file diff --git a/lib/background/background.dart b/lib/background/background.dart index 2816f844..d0e61461 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -17,6 +17,9 @@ bool isAppForeground = false; Future serviceMain(ServiceInstance service) async { // If on Android, set up a permanent notification so the OS won't kill it. if (service is AndroidServiceInstance) { + + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + service.setAsForegroundService(); const androidDetails = AndroidNotificationDetails( 'mostro_foreground', @@ -24,13 +27,14 @@ Future serviceMain(ServiceInstance service) async { icon: '@drawable/ic_bg_service_small', priority: Priority.high, importance: Importance.max, + ongoing: true, + autoCancel: false, ); const notificationDetails = NotificationDetails( android: androidDetails, ); - final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); await flutterLocalNotificationsPlugin.show( Config.notificationId, 'Mostro is running', diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index 6e70e964..45ff354d 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -27,7 +27,7 @@ class MobileBackgroundService implements BackgroundService { androidConfiguration: AndroidConfiguration( autoStart: true, onStart: serviceMain, - isForegroundMode: false, + isForegroundMode: true, autoStartOnBoot: true, initialNotificationContent: "Mostro P2P", foregroundServiceTypes: [ @@ -42,7 +42,11 @@ class MobileBackgroundService implements BackgroundService { @override void subscribe(List filters) { + final subId = DateTime.now().millisecondsSinceEpoch.toString(); + _subscriptions[subId] = {'filters': filters}; + service.invoke('create-subscription', { + 'id': subId, 'filters': filters.map((f) => f.toMap()).toList(), }); } @@ -79,6 +83,10 @@ class MobileBackgroundService implements BackgroundService { @override Future setForegroundStatus(bool isForeground) async { + service.invoke('app-foreground-status', { + 'is-foreground': isForeground, + }); + if (isForeground) { await _stopService(); } else { From 27193bf2c8fd45368b773422c8d58131420f98df Mon Sep 17 00:00:00 2001 From: Biz Date: Fri, 25 Apr 2025 11:04:39 +1000 Subject: [PATCH 108/149] Mo background changes --- lib/background/background.dart | 27 +---------------- lib/background/mobile_background_service.dart | 29 ++++++++++++++----- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/lib/background/background.dart b/lib/background/background.dart index d0e61461..7181ac5f 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -2,8 +2,6 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/nostr_filter.dart'; import 'package:mostro_mobile/data/repositories/event_storage.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; @@ -11,36 +9,13 @@ import 'package:mostro_mobile/notifications/notification_service.dart'; import 'package:mostro_mobile/services/nostr_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; -bool isAppForeground = false; +bool isAppForeground = true; @pragma('vm:entry-point') Future serviceMain(ServiceInstance service) async { // If on Android, set up a permanent notification so the OS won't kill it. if (service is AndroidServiceInstance) { - - final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - service.setAsForegroundService(); - const androidDetails = AndroidNotificationDetails( - 'mostro_foreground', - 'Mostro Foreground Service', - icon: '@drawable/ic_bg_service_small', - priority: Priority.high, - importance: Importance.max, - ongoing: true, - autoCancel: false, - ); - - const notificationDetails = NotificationDetails( - android: androidDetails, - ); - - await flutterLocalNotificationsPlugin.show( - Config.notificationId, - 'Mostro is running', - 'Connected to Mostro serivce', - notificationDetails, - ); } final Map> activeSubscriptions = {}; diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index 45ff354d..42262b9f 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -29,7 +29,8 @@ class MobileBackgroundService implements BackgroundService { onStart: serviceMain, isForegroundMode: true, autoStartOnBoot: true, - initialNotificationContent: "Mostro P2P", + initialNotificationTitle: "Mostro P2P", + initialNotificationContent: "Connected to Mostro service", foregroundServiceTypes: [ AndroidForegroundType.dataSync, ]), @@ -38,6 +39,14 @@ class MobileBackgroundService implements BackgroundService { service.on('on-start').listen((data) { _isRunning = true; }); + + service.on('on-stop').listen((event) { + _isRunning = false; + }); + + service.invoke('start', { + 'settings': _settings.toJson(), + }); } @override @@ -83,24 +92,30 @@ class MobileBackgroundService implements BackgroundService { @override Future setForegroundStatus(bool isForeground) async { + // Always inform the service about status change service.invoke('app-foreground-status', { 'is-foreground': isForeground, }); + // Check current running state first + final isCurrentlyRunning = await service.isRunning(); + if (isForeground) { - await _stopService(); + // Only stop if actually running + if (isCurrentlyRunning) { + await _stopService(); + } } else { - await _startService(); + // Only start if not already running + if (!isCurrentlyRunning) { + await _startService(); + } } } Future _startService() async { await service.startService(); - while (!(await service.isRunning())) { - await Future.delayed(const Duration(milliseconds: 50)); - } - service.invoke('start', { 'settings': _settings.toJson(), }); From 66a1f5cc05ca2a2046972d67e5ce27f4c5a95840 Mon Sep 17 00:00:00 2001 From: Biz Date: Fri, 25 Apr 2025 12:00:10 +1000 Subject: [PATCH 109/149] Avoid race conditions when app is backgrounded --- lib/background/background.dart | 2 + lib/background/mobile_background_service.dart | 64 +++++++++++++++++-- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/lib/background/background.dart b/lib/background/background.dart index 7181ac5f..1e26dd88 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -35,6 +35,8 @@ Future serviceMain(ServiceInstance service) async { final settings = Settings.fromJson(settingsMap); await nostrService.init(settings); + + service.invoke('service-ready', {}); }); service.on('create-subscription').listen((data) { diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index 42262b9f..344d1695 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/background/background.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'abstract_background_service.dart'; @@ -15,6 +16,9 @@ class MobileBackgroundService implements BackgroundService { final _subscriptions = >{}; bool _isRunning = false; + final _logger = Logger(); + bool _serviceReady = false; + final List _pendingOperations = []; @override Future init() async { @@ -25,7 +29,7 @@ class MobileBackgroundService implements BackgroundService { onBackground: onIosBackground, ), androidConfiguration: AndroidConfiguration( - autoStart: true, + autoStart: false, onStart: serviceMain, isForegroundMode: true, autoStartOnBoot: true, @@ -38,14 +42,23 @@ class MobileBackgroundService implements BackgroundService { service.on('on-start').listen((data) { _isRunning = true; + service.invoke('start', { + 'settings': _settings.toJson(), + }); + _logger.d( + 'Service started with settings: ${_settings.toJson()}', + ); }); service.on('on-stop').listen((event) { _isRunning = false; + _logger.i('Service stopped'); }); - service.invoke('start', { - 'settings': _settings.toJson(), + service.on('service-ready').listen((data) { + _logger.i("Service confirmed it's ready"); + _serviceReady = true; + _processPendingOperations(); }); } @@ -54,9 +67,12 @@ class MobileBackgroundService implements BackgroundService { final subId = DateTime.now().millisecondsSinceEpoch.toString(); _subscriptions[subId] = {'filters': filters}; - service.invoke('create-subscription', { - 'id': subId, - 'filters': filters.map((f) => f.toMap()).toList(), + _executeWhenReady(() { + _logger.i("Sending subscription to service"); + service.invoke('create-subscription', { + 'id': subId, + 'filters': filters.map((f) => f.toMap()).toList(), + }); }); } @@ -108,14 +124,29 @@ class MobileBackgroundService implements BackgroundService { } else { // Only start if not already running if (!isCurrentlyRunning) { - await _startService(); + try { + await _startService(); + } catch (e) { + _logger.e('Error starting service: $e'); + // Retry with a delay if needed + await Future.delayed(Duration(seconds: 1)); + await _startService(); + } } } } Future _startService() async { + _logger.i("Starting service"); await service.startService(); + _serviceReady = false; // Reset ready state when starting + + // Wait for the service to be running + while (!(await service.isRunning())) { + await Future.delayed(const Duration(milliseconds: 50)); + } + _logger.i("Service running, sending settings"); service.invoke('start', { 'settings': _settings.toJson(), }); @@ -133,4 +164,23 @@ class MobileBackgroundService implements BackgroundService { @override bool get isRunning => _isRunning; + + // Method to execute operations when service is ready + void _executeWhenReady(Function operation) { + if (_serviceReady) { + operation(); + } else { + _pendingOperations.add(operation); + } + } + +// Method to process pending operations + void _processPendingOperations() { + if (_serviceReady) { + for (final operation in _pendingOperations) { + operation(); + } + _pendingOperations.clear(); + } + } } From 471d6155ebb0b99f1e1a2811709504ab9850e23c Mon Sep 17 00:00:00 2001 From: Biz Date: Fri, 25 Apr 2025 12:40:51 +1000 Subject: [PATCH 110/149] Add logging and improve app state transitions between foreground/background --- lib/services/lifecycle_manager.dart | 78 +++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart index fae814fa..f02eed69 100644 --- a/lib/services/lifecycle_manager.dart +++ b/lib/services/lifecycle_manager.dart @@ -1,14 +1,18 @@ import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/features/trades/providers/trades_provider.dart'; import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; class LifecycleManager extends WidgetsBindingObserver { final Ref ref; bool _isInBackground = false; final List _activeSubscriptions = []; + final _logger = Logger(); LifecycleManager(this.ref) { WidgetsBinding.instance.addObserver(this); @@ -37,25 +41,67 @@ class LifecycleManager extends WidgetsBindingObserver { } Future _switchToForeground() async { - _isInBackground = false; - // Clear active subscriptions - _activeSubscriptions.clear(); - // Stop background service - final backgroundService = ref.read(backgroundServiceProvider); - await backgroundService.setForegroundStatus(true); - // Reinitialize the mostro service - ref.read(mostroServiceProvider).init(); - // Reinitialize chat rooms - final chatRooms = ref.read(chatRoomsNotifierProvider.notifier); - await chatRooms.loadChats(); + try { + _isInBackground = false; + _logger.i("Switching to foreground"); + + // Clear active subscriptions + _activeSubscriptions.clear(); + + // Stop background service + final backgroundService = ref.read(backgroundServiceProvider); + await backgroundService.setForegroundStatus(true); + _logger.i("Background service foreground status set to true"); + + // Add a small delay to ensure the background service has fully transitioned + await Future.delayed(const Duration(milliseconds: 500)); + + // Reinitialize the mostro service + _logger.i("Reinitializing MostroService"); + ref.read(mostroServiceProvider).init(); + + // Refresh order repository by re-reading it + _logger.i("Refreshing order repository"); + // Force reinitialization by invalidating the provider + ref.invalidate(orderRepositoryProvider); + // Read it again to trigger reinitialization + ref.read(orderRepositoryProvider); + + // Reinitialize chat rooms + _logger.i("Reloading chat rooms"); + final chatRooms = ref.read(chatRoomsNotifierProvider.notifier); + await chatRooms.loadChats(); + + // Force UI update for trades + _logger.i("Invalidating providers to refresh UI"); + ref.invalidate(filteredTradesProvider); + + _logger.i("Foreground transition complete"); + } catch (e) { + _logger.e("Error during foreground transition: $e"); + } } Future _switchToBackground() async { - _isInBackground = true; - // Transfer active subscriptions to background service - final backgroundService = ref.read(backgroundServiceProvider); - await backgroundService.setForegroundStatus(false); - backgroundService.subscribe(_activeSubscriptions); + try { + _isInBackground = true; + _logger.i("Switching to background"); + + // Transfer active subscriptions to background service + final backgroundService = ref.read(backgroundServiceProvider); + await backgroundService.setForegroundStatus(false); + + if (_activeSubscriptions.isNotEmpty) { + _logger.i("Transferring ${_activeSubscriptions.length} active subscriptions to background service"); + backgroundService.subscribe(_activeSubscriptions); + } else { + _logger.w("No active subscriptions to transfer to background service"); + } + + _logger.i("Background transition complete"); + } catch (e) { + _logger.e("Error during background transition: $e"); + } } void addSubscription(NostrFilter filter) { From df567e44881e39b421e24f0fdaed8fea1ea168cf Mon Sep 17 00:00:00 2001 From: Biz Date: Fri, 25 Apr 2025 13:37:05 +1000 Subject: [PATCH 111/149] Add NostrResponsiveButton widget and refactor order repository reload logic --- .../repositories/open_orders_repository.dart | 9 + lib/services/lifecycle_manager.dart | 6 +- .../widgets/nostr_responsive_button.dart | 218 ++++++++++++++++++ .../nostr_responsive_button_example.dart | 134 +++++++++++ 4 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 lib/shared/widgets/nostr_responsive_button.dart create mode 100644 lib/shared/widgets/nostr_responsive_button_example.dart diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 05f04811..eceef90c 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -110,4 +110,13 @@ class OpenOrdersRepository implements OrderRepository { _settings = settings.copyWith(); } } + + Future reloadData() async { + _logger.i('Reloading repository data'); + // Clear existing events + _events.clear(); + // Then resubscribe for future updates + _subscribeToOrders(); + } + } diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart index f02eed69..98f435df 100644 --- a/lib/services/lifecycle_manager.dart +++ b/lib/services/lifecycle_manager.dart @@ -62,10 +62,8 @@ class LifecycleManager extends WidgetsBindingObserver { // Refresh order repository by re-reading it _logger.i("Refreshing order repository"); - // Force reinitialization by invalidating the provider - ref.invalidate(orderRepositoryProvider); - // Read it again to trigger reinitialization - ref.read(orderRepositoryProvider); + final orderRepo = ref.read(orderRepositoryProvider); + await orderRepo.reloadData(); // Reinitialize chat rooms _logger.i("Reloading chat rooms"); diff --git a/lib/shared/widgets/nostr_responsive_button.dart b/lib/shared/widgets/nostr_responsive_button.dart new file mode 100644 index 00000000..dcd7bd31 --- /dev/null +++ b/lib/shared/widgets/nostr_responsive_button.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; + +enum ButtonStyleType { raised, outlined, text } + +/// A button specially designed for Nostr operations that shows loading state +/// and handles the unique event-based nature of Nostr protocols. +class NostrResponsiveButton extends ConsumerStatefulWidget { + /// Button text + final String label; + + /// Button style type + final ButtonStyleType buttonStyle; + + /// The operation to perform when the button is pressed + final VoidCallback onPressed; + + /// A provider that tracks the state of the operation - should emit a value when operation completes + final StateProvider completionProvider; + + /// A provider that tracks if there was an error + final StateProvider errorProvider; + + /// Optional callback when operation completes successfully + final VoidCallback? onOperationComplete; + + /// Optional callback when the operation fails + final Function(String error)? onOperationError; + + /// How long to wait before timing out + final Duration timeout; + + /// Default error message to show + final String defaultErrorMessage; + + /// Width of the button, if null it uses the parent's constraints + final double? width; + + /// Height of the button, defaults to 48 + final double height; + + final bool showSuccessIndicator; + + const NostrResponsiveButton({ + super.key, + required this.label, + required this.buttonStyle, + required this.onPressed, + required this.completionProvider, + required this.errorProvider, + this.onOperationComplete, + this.onOperationError, + this.timeout = const Duration(seconds: 30), // Nostr operations can take longer + this.defaultErrorMessage = 'Operation failed. Please try again.', + this.width, + this.height = 48, + this.showSuccessIndicator = false, + }); + + @override + ConsumerState createState() => _NostrResponsiveButtonState(); +} + +class _NostrResponsiveButtonState extends ConsumerState { + bool _loading = false; + bool _showSuccess = false; + final _logger = Logger(); + Timer? _timeoutTimer; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _timeoutTimer?.cancel(); + super.dispose(); + } + + void _startOperation() { + setState(() { + _loading = true; + _showSuccess = false; + }); + + // Reset state providers + ref.read(widget.completionProvider.notifier).state = false; + ref.read(widget.errorProvider.notifier).state = null; + + // Start the operation + widget.onPressed(); + + // Start timeout timer + _timeoutTimer = Timer(widget.timeout, _handleTimeout); + } + + void _handleTimeout() { + if (_loading) { + _logger.w('Operation timed out after ${widget.timeout.inSeconds} seconds'); + setState(() { + _loading = false; + }); + + final errorMsg = 'Operation timed out. Please try again.'; + ref.read(widget.errorProvider.notifier).state = errorMsg; + + if (widget.onOperationError != null) { + widget.onOperationError!(errorMsg); + } else { + _showErrorSnackbar(errorMsg); + } + } + } + + void _showErrorSnackbar(String message) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } + + void _handleCompletion() { + _timeoutTimer?.cancel(); + + setState(() { + _loading = false; + if (widget.showSuccessIndicator) { + _showSuccess = true; + // Reset success indicator after a short delay + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + _showSuccess = false; + }); + } + }); + } + }); + + if (widget.onOperationComplete != null) { + widget.onOperationComplete!(); + } + } + + void _handleError(String error) { + _timeoutTimer?.cancel(); + + setState(() { + _loading = false; + }); + + if (widget.onOperationError != null) { + widget.onOperationError!(error); + } else { + _showErrorSnackbar(error); + } + } + + @override + Widget build(BuildContext context) { + // Listen to completion + final isComplete = ref.watch(widget.completionProvider); + if (isComplete && _loading) { + _handleCompletion(); + } + + // Listen to errors + final error = ref.watch(widget.errorProvider); + if (error != null && _loading) { + _handleError(error); + } + + Widget childWidget; + if (_loading) { + childWidget = const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } else if (_showSuccess) { + childWidget = const Icon(Icons.check_circle, color: Colors.green); + } else { + childWidget = Text(widget.label); + } + + Widget button; + switch (widget.buttonStyle) { + case ButtonStyleType.raised: + button = ElevatedButton( + onPressed: _loading ? null : _startOperation, + child: childWidget, + ); + break; + case ButtonStyleType.outlined: + button = OutlinedButton( + onPressed: _loading ? null : _startOperation, + child: childWidget, + ); + break; + case ButtonStyleType.text: + button = TextButton( + onPressed: _loading ? null : _startOperation, + child: childWidget, + ); + break; + } + + return SizedBox( + width: widget.width, + height: widget.height, + child: button, + ); + } +} diff --git a/lib/shared/widgets/nostr_responsive_button_example.dart b/lib/shared/widgets/nostr_responsive_button_example.dart new file mode 100644 index 00000000..266df462 --- /dev/null +++ b/lib/shared/widgets/nostr_responsive_button_example.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mostro_mobile/shared/widgets/nostr_responsive_button.dart'; + +// Example providers for tracking operation state +final orderOperationCompleteProvider = StateProvider((ref) => false); +final orderOperationErrorProvider = StateProvider((ref) => null); + +class NostrResponsiveButtonExample extends ConsumerWidget { + final String orderId; + + const NostrResponsiveButtonExample({ + super.key, + required this.orderId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('Example Order Screen')), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Order details would go here + const SizedBox(height: 24), + + // Example of take order button + NostrResponsiveButton( + label: 'Take Order', + buttonStyle: ButtonStyleType.raised, + completionProvider: orderOperationCompleteProvider, + errorProvider: orderOperationErrorProvider, + onPressed: () { + // This is where you call your order notifier + _takeOrder(ref, context); + }, + onOperationComplete: () { + // Navigate when complete - optional, could also be handled in the notifier + context.push('/order-success/$orderId'); + }, + timeout: const Duration(seconds: 30), + ), + + const SizedBox(height: 16), + + // Example of cancel order button + NostrResponsiveButton( + label: 'Cancel Order', + buttonStyle: ButtonStyleType.outlined, + completionProvider: orderOperationCompleteProvider, + errorProvider: orderOperationErrorProvider, + onPressed: () { + _cancelOrder(ref, context); + }, + // Red text for cancel button + width: double.infinity, + ), + ], + ), + ), + ); + } + + void _takeOrder(WidgetRef ref, BuildContext context) { + // 1. Call your order notifier's method + final orderNotifier = ref.read(orderNotifierProvider); + orderNotifier.takeOrder(orderId); + + // 2. Set up a listener in your notifier or service to update the completion state + // This would be done in your OrderNotifier's implementation: + + /* + Future takeOrder(String orderId) async { + try { + // Start the process of taking the order via Nostr + final order = await _nostrService.takeOrder(orderId); + + // Listen for confirmation events (this would depend on your Nostr implementation) + _subscription = _nostrService.subscribeToOrderUpdates(orderId).listen((event) { + if (event.status == 'accepted') { + // Signal that the operation completed successfully + ref.read(orderOperationCompleteProvider.notifier).state = true; + } else if (event.status == 'error') { + // Signal that there was an error + ref.read(orderOperationErrorProvider.notifier).state = event.errorMessage; + } + }); + } catch (e) { + // Handle initial errors + ref.read(orderOperationErrorProvider.notifier).state = e.toString(); + } + } + */ + } + + void _cancelOrder(WidgetRef ref, BuildContext context) { + // Similar to takeOrder but for cancellation + + // In a real implementation, you would: + // 1. Call your cancel order method + // 2. Set up listeners for success/failure + // 3. Update the state providers accordingly + + // This is just a simulation for the example + Future.delayed(const Duration(seconds: 2), () { + // Simulate success + ref.read(orderOperationCompleteProvider.notifier).state = true; + }); + } +} + +// Stand-in for your actual order notifier provider +final orderNotifierProvider = Provider((ref) { + return OrderNotifier(ref); +}); + +// Simple stand-in for your actual OrderNotifier class +class OrderNotifier { + final Ref ref; + + OrderNotifier(this.ref); + + Future takeOrder(String orderId) async { + // Implementation would go here + // After getting a result, update the state providers + } + + Future cancelOrder(String orderId) async { + // Implementation would go here + } +} From d9bc94baa458c604495af8f5a3649dfc0fdc4aff Mon Sep 17 00:00:00 2001 From: Biz Date: Fri, 25 Apr 2025 21:48:15 +1000 Subject: [PATCH 112/149] Add async state handling and refresh functionality to trades screen --- .../trades/providers/trades_provider.dart | 37 +++++++--- .../trades/screens/trades_screen.dart | 67 +++++++++++++++++-- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/lib/features/trades/providers/trades_provider.dart b/lib/features/trades/providers/trades_provider.dart index 9df86346..0cb02587 100644 --- a/lib/features/trades/providers/trades_provider.dart +++ b/lib/features/trades/providers/trades_provider.dart @@ -1,28 +1,45 @@ +import 'dart:math' as math; import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; -final filteredTradesProvider = Provider>((ref) { +final _logger = Logger(); + +final filteredTradesProvider = Provider>>((ref) { final allOrdersAsync = ref.watch(orderEventsProvider); final sessions = ref.watch(sessionNotifierProvider); - - return allOrdersAsync.maybeWhen( + + _logger.d('Filtering trades: Orders state=${allOrdersAsync.toString().substring(0, math.min(100, allOrdersAsync.toString().length))}, Sessions count=${sessions.length}'); + + return allOrdersAsync.when( data: (allOrders) { final orderIds = sessions.map((s) => s.orderId).toSet(); - - allOrders - .sort((o1, o2) => o1.expirationDate.compareTo(o2.expirationDate)); - - final filtered = allOrders.reversed + _logger.d('Got ${allOrders.length} orders and ${orderIds.length} sessions'); + + // Make a copy to avoid modifying the original list + final sortedOrders = List.from(allOrders); + sortedOrders.sort((o1, o2) => o1.expirationDate.compareTo(o2.expirationDate)); + + final filtered = sortedOrders.reversed .where((o) => orderIds.contains(o.orderId)) .where((o) => o.status != Status.canceled) .where((o) => o.status != Status.canceledByAdmin) .toList(); - return filtered; + + _logger.d('Filtered to ${filtered.length} trades'); + return AsyncValue.data(filtered); + }, + loading: () { + _logger.d('Orders loading'); + return const AsyncValue.loading(); + }, + error: (error, stackTrace) { + _logger.e('Error filtering trades: $error'); + return AsyncValue.error(error, stackTrace); }, - orElse: () => [], ); }); diff --git a/lib/features/trades/screens/trades_screen.dart b/lib/features/trades/screens/trades_screen.dart index 8474ab4f..88e8bf7f 100644 --- a/lib/features/trades/screens/trades_screen.dart +++ b/lib/features/trades/screens/trades_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/order_filter.dart'; import 'package:mostro_mobile/features/trades/providers/trades_provider.dart'; import 'package:mostro_mobile/features/trades/widgets/trades_list.dart'; @@ -15,15 +16,19 @@ class TradesScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(filteredTradesProvider); - + // Watch the async trades data + final tradesAsync = ref.watch(filteredTradesProvider); + return Scaffold( backgroundColor: AppTheme.dark1, appBar: const MostroAppBar(), drawer: const MostroAppDrawer(), body: RefreshIndicator( onRefresh: () async { - return await ref.refresh(filteredTradesProvider); + // Force reload the orders repository first + await ref.read(orderRepositoryProvider).reloadData(); + // Then refresh the filtered trades provider + ref.invalidate(filteredTradesProvider); }, child: Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), @@ -40,10 +45,50 @@ class TradesScreen extends ConsumerWidget { style: TextStyle(color: AppTheme.mostroGreen), ), ), - _buildFilterButton(context, state), + // Use the async value pattern to handle different states + tradesAsync.when( + data: (trades) => _buildFilterButton(context, trades), + loading: () => _buildFilterButton(context, []), + error: (error, _) => _buildFilterButton(context, []), + ), const SizedBox(height: 6.0), Expanded( - child: _buildOrderList(state), + child: tradesAsync.when( + data: (trades) => _buildOrderList(trades), + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (error, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 60, + ), + const SizedBox(height: 16), + Text( + 'Error loading trades', + style: TextStyle(color: AppTheme.cream1), + ), + Text( + error.toString(), + style: TextStyle(color: AppTheme.cream1, fontSize: 12), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + ref.invalidate(orderEventsProvider); + ref.invalidate(filteredTradesProvider); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ), ), const BottomNavBar(), ], @@ -57,6 +102,7 @@ class TradesScreen extends ConsumerWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ OutlinedButton.icon( onPressed: () { @@ -87,6 +133,17 @@ class TradesScreen extends ConsumerWidget { "${trades.length} trades", style: const TextStyle(color: AppTheme.cream1), ), + // Add a manual refresh button + IconButton( + icon: const Icon(Icons.refresh, color: AppTheme.cream1), + onPressed: () { + // Get the riverpod context from the widget + final container = ProviderScope.containerOf(context, listen: false); + // Invalidate providers to force refresh + container.invalidate(orderEventsProvider); + container.invalidate(filteredTradesProvider); + }, + ), ], ), ); From 8a1d69e520d74d051b931053ec2435e69dc0149e Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 26 Apr 2025 12:29:33 +1000 Subject: [PATCH 113/149] Refactor buttons using NostrResponsiveButton and improve state management for trade actions --- .../order/screens/add_order_screen.dart | 115 ++++-- .../trades/screens/trade_detail_screen.dart | 384 +++++++++++++++--- lib/notifications/notification_service.dart | 20 +- .../widgets/nostr_responsive_button.dart | 52 ++- lib/shared/widgets/responsive_button.dart | 95 ----- 5 files changed, 449 insertions(+), 217 deletions(-) delete mode 100644 lib/shared/widgets/responsive_button.dart diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index eefbf5c5..76a64650 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/data/models/cant_do.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as nostr_action; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/order/widgets/fixed_switch_widget.dart'; @@ -12,8 +14,12 @@ import 'package:mostro_mobile/features/order/providers/order_notifier_provider.d import 'package:mostro_mobile/shared/widgets/currency_combo_box.dart'; import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; +import 'package:mostro_mobile/shared/widgets/nostr_responsive_button.dart'; import 'package:uuid/uuid.dart'; +final orderSubmissionCompleteProvider = StateProvider((ref) => false); +final orderSubmissionErrorProvider = StateProvider((ref) => null); + class AddOrderScreen extends ConsumerStatefulWidget { const AddOrderScreen({super.key}); @@ -435,38 +441,52 @@ class _AddOrderScreenState extends ConsumerState { /// /// ACTION BUTTONS /// - Widget _buildActionButtons( - BuildContext context, WidgetRef ref, OrderType orderType) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => context.go('/'), - child: const Text('CANCEL', style: TextStyle(color: AppTheme.red2)), - ), - const SizedBox(width: 16), - ElevatedButton( - onPressed: () { - if (_formKey.currentState?.validate() ?? false) { - _submitOrder(context, ref, orderType); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('SUBMIT'), - ), - ], - ); - } - +Widget _buildActionButtons( + BuildContext context, WidgetRef ref, OrderType orderType) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: () { + context.pop(); + }, + child: const Text('CANCEL'), + ), + const SizedBox(width: 8.0), + NostrResponsiveButton( + label: 'SUBMIT', + buttonStyle: ButtonStyleType.raised, + width: 120, + // Reference the state providers we created + completionProvider: orderSubmissionCompleteProvider, + errorProvider: orderSubmissionErrorProvider, + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + _submitOrder(context, ref, orderType); + } + }, + // Navigate when the operation completes + onOperationComplete: () { + // Navigate to the orders screen or detail screen + context.go('/'); // Or navigate to a more specific page + }, + // Show a success indicator briefly before navigating + showSuccessIndicator: true, + ), + ], + ); +} /// /// SUBMIT ORDER /// void _submitOrder(BuildContext context, WidgetRef ref, OrderType orderType) { + // Reset state providers + ref.read(orderSubmissionCompleteProvider.notifier).state = false; + ref.read(orderSubmissionErrorProvider.notifier).state = null; + final selectedFiatCode = ref.read(selectedFiatCodeProvider); - if (_formKey.currentState?.validate() ?? false) { + try { // Generate a unique temporary ID for this new order final uuid = Uuid(); final tempOrderId = uuid.v4(); @@ -474,6 +494,12 @@ class _AddOrderScreenState extends ConsumerState { addOrderNotifierProvider(tempOrderId).notifier, ); + // Calculate the request ID the same way AddOrderNotifier does + final requestId = BigInt.parse( + tempOrderId.replaceAll('-', ''), + radix: 16, + ).toUnsigned(64).toInt(); + final fiatAmount = _maxFiatAmount != null ? 0 : _minFiatAmount; final minAmount = _maxFiatAmount != null ? _minFiatAmount : null; final maxAmount = _maxFiatAmount; @@ -497,7 +523,44 @@ class _AddOrderScreenState extends ConsumerState { buyerInvoice: buyerInvoice, ); + // Submit the order first notifier.submitOrder(order); + + // Then listen for events + ref.listen( + addOrderEventsProvider(requestId), + (previous, next) { + next.when( + data: (message) { + if (message.action == nostr_action.Action.newOrder && message.id != null) { + // Order was successfully created + ref.read(orderSubmissionCompleteProvider.notifier).state = true; + } else if (message.payload is CantDo) { + // Order creation failed with a CantDo response + final cantDo = message.getPayload(); + ref.read(orderSubmissionErrorProvider.notifier).state = + cantDo?.cantDoReason.toString() ?? 'Failed to create order'; + } + }, + error: (error, stack) { + ref.read(orderSubmissionErrorProvider.notifier).state = error.toString(); + }, + loading: () {} + ); + }, + ); + + // Set a timeout for the operation + Future.delayed(const Duration(seconds: 20), () { + if (!ref.read(orderSubmissionCompleteProvider) && + ref.read(orderSubmissionErrorProvider) == null) { + // We can't cancel the listener, but we can update the UI + ref.read(orderSubmissionErrorProvider.notifier).state = 'Operation timed out'; + } + }); + } catch (e) { + // If there's an exception, update the error provider + ref.read(orderSubmissionErrorProvider.notifier).state = e.toString(); } } } diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 64e465b1..81886adc 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -17,6 +17,7 @@ import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; +import 'package:mostro_mobile/shared/widgets/nostr_responsive_button.dart'; class TradeDetailScreen extends ConsumerWidget { final String orderId; @@ -58,7 +59,10 @@ class TradeDetailScreen extends ConsumerWidget { alignment: WrapAlignment.center, spacing: 10, runSpacing: 10, - children: _buildActionButtons(context, ref, order), + children: [ + _buildCloseButton(context), + ..._buildActionButtons(context, ref, order), + ], ), ], ), @@ -182,99 +186,351 @@ class TradeDetailScreen extends ConsumerWidget { /// Main action button area, switching on `order.status`. /// Additional checks use `message.action` to refine which button to show. + /// Following the Mostro protocol state machine for order flow. List _buildActionButtons( BuildContext context, WidgetRef ref, NostrEvent order) { - final message = ref.watch(orderNotifierProvider(orderId)); + final message = ref.watch(orderNotifierProvider(orderId)); final session = ref.watch(sessionProvider(orderId)); + final userRole = session?.role; + + // State providers for NostrResponsiveButton + final completeProvider = StateProvider((ref) => false); + final errorProvider = StateProvider((ref) => null); // The finite-state-machine approach: decide based on the order.status. - // Then refine if needed using the last action in `message.action`. + // Then refine based on the user's role and the last action. switch (order.status) { case Status.pending: - return [ - //_buildCloseButton(context), - _buildCancelButton(context, ref), - if (message.action == actions.Action.addInvoice) - _buildAddInvoiceButton(context), - ]; + // According to Mostro FSM: Pending state + final widgets = []; + + // FSM: In pending state, seller can cancel + widgets.add(_buildNostrButton( + 'CANCEL', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), + )); + + return widgets; + case Status.waitingPayment: - return [ - //_buildCloseButton(context), - _buildCancelButton(context, ref), - _buildPayInvoiceButton(context), - ]; + // According to Mostro FSM: waiting-payment state + final widgets = []; + + // FSM: Seller can pay-invoice and cancel + if (userRole == Role.seller) { + widgets.add(_buildNostrButton( + 'PAY INVOICE', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.mostroGreen, + onPressed: () => context.push('/pay_invoice/${orderId}'), + )); + + widgets.add(_buildNostrButton( + 'CANCEL', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), + )); + } + // FSM: Buyer can do nothing in waiting-payment state + return widgets; case Status.waitingBuyerInvoice: - return [ - //_buildCloseButton(context), - _buildCancelButton(context, ref), - if (message.action == actions.Action.addInvoice) - _buildAddInvoiceButton(context), - ]; + // According to Mostro FSM: waiting-buyer-invoice state + final widgets = []; + + // FSM: Buyer can add-invoice and cancel + if (userRole == Role.buyer) { + widgets.add(_buildNostrButton( + 'ADD INVOICE', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.mostroGreen, + onPressed: () => context.push('/add_invoice/${orderId}'), + )); + + widgets.add(_buildNostrButton( + 'CANCEL', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), + )); + } + // FSM: Seller can do nothing in waiting-buyer-invoice state + + return widgets; + case Status.settledHoldInvoice: - return [ - _buildCloseButton(context), - if (message.action == actions.Action.rate) _buildRateButton(context), - ]; + // According to Mostro FSM: settled-hold-invoice state + // Both buyer and seller can only wait + final widgets = []; + + // Only show rate button if that action is available + if (message.action == actions.Action.rate) { + widgets.add(_buildNostrButton( + 'RATE', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.mostroGreen, + onPressed: () => context.push('/rate_user/${orderId}'), + )); + } + + return widgets; + case Status.active: - return [ - //_buildCloseButton(context), - _buildCancelButton(context, ref), - _buildContactButton(context), - // If user has not opened a dispute already + // According to Mostro FSM: active state + final widgets = []; + + // Role-specific actions according to FSM + if (userRole == Role.buyer) { + // FSM: Buyer can fiat-sent + if (message.action != actions.Action.fiatSentOk && + message.action != actions.Action.fiatSent) { + widgets.add(_buildNostrButton( + 'FIAT SENT', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.mostroGreen, + onPressed: () => ref + .read(orderNotifierProvider(orderId).notifier) + .sendFiatSent(), + )); + } + + // FSM: Buyer can cancel + widgets.add(_buildNostrButton( + 'CANCEL', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), + )); + + // FSM: Buyer can dispute if (message.action != actions.Action.disputeInitiatedByYou && message.action != actions.Action.disputeInitiatedByPeer && - message.action != actions.Action.rate) - _buildDisputeButton(ref), - // If the action is "addInvoice" => show a button for the invoice screen. - if (message.action == actions.Action.addInvoice) - _buildAddInvoiceButton(context), - // If the order is waiting for buyer to confirm fiat was sent - if (session!.role == Role.buyer) _buildFiatSentButton(ref), - // If the user is the seller & the buyer is done => show release button - if (session.role == Role.seller) _buildReleaseButton(ref), - // If the user is ready to rate - if (message.action == actions.Action.rate) _buildRateButton(context), - ]; + message.action != actions.Action.dispute) { + widgets.add(_buildNostrButton( + 'DISPUTE', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => ref + .read(orderNotifierProvider(orderId).notifier) + .disputeOrder(), + )); + } + } else if (userRole == Role.seller) { + // FSM: Seller can cancel + widgets.add(_buildNostrButton( + 'CANCEL', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), + )); + + // FSM: Seller can dispute + if (message.action != actions.Action.disputeInitiatedByYou && + message.action != actions.Action.disputeInitiatedByPeer && + message.action != actions.Action.dispute) { + widgets.add(_buildNostrButton( + 'DISPUTE', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => ref + .read(orderNotifierProvider(orderId).notifier) + .disputeOrder(), + )); + } + } + + // Rate button if applicable (common for both roles) + if (message.action == actions.Action.rate) { + widgets.add(_buildNostrButton( + 'RATE', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.mostroGreen, + onPressed: () => context.push('/rate_user/${orderId}'), + )); + } + + return widgets; case Status.fiatSent: - // Usually the user can open dispute if the other side doesn't confirm, - // or just close the screen and wait. - return [ - //_buildCloseButton(context), - if (session!.role == Role.seller) _buildReleaseButton(ref), - _buildDisputeButton(ref), - ]; + // According to Mostro FSM: fiat-sent state + final widgets = []; + + if (userRole == Role.seller) { + // FSM: Seller can release + widgets.add(_buildNostrButton( + 'RELEASE SATS', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.mostroGreen, + onPressed: () => ref + .read(orderNotifierProvider(orderId).notifier) + .releaseOrder(), + )); + + // FSM: Seller can cancel + widgets.add(_buildNostrButton( + 'CANCEL', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), + )); + + // FSM: Seller can dispute + widgets.add(_buildNostrButton( + 'DISPUTE', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => ref + .read(orderNotifierProvider(orderId).notifier) + .disputeOrder(), + )); + } else if (userRole == Role.buyer) { + // FSM: Buyer can only dispute in fiat-sent state + widgets.add(_buildNostrButton( + 'DISPUTE', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => ref + .read(orderNotifierProvider(orderId).notifier) + .disputeOrder(), + )); + } + + return widgets; case Status.cooperativelyCanceled: - return [ - //_buildCloseButton(context), - if (message.action == actions.Action.cooperativeCancelInitiatedByPeer) - _buildCancelButton(context, ref), - ]; + // According to Mostro FSM: cooperatively-canceled state + final widgets = []; + + // Add confirm cancel if cooperative cancel was initiated by peer + if (message.action == actions.Action.cooperativeCancelInitiatedByPeer) { + widgets.add(_buildNostrButton( + 'CONFIRM CANCEL', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), + )); + } + + return widgets; case Status.success: - return [ - //_buildCloseButton(context), - if (message.action != actions.Action.rateReceived) - _buildRateButton(context), - ]; + // According to Mostro FSM: success state + // Both buyer and seller can only rate + final widgets = []; + + // FSM: Both roles can rate counterparty if not already rated + if (message.action != actions.Action.rateReceived) { + widgets.add(_buildNostrButton( + 'RATE', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.mostroGreen, + onPressed: () => context.push('/rate_user/${orderId}'), + )); + } + + return widgets; + case Status.inProgress: - return [ - _buildCancelButton(context, ref), - ]; + // According to Mostro FSM: in-progress is a transitional state + // This is not explicitly in the FSM but we follow cancel rules as active state + final widgets = []; + + // Both roles can cancel during in-progress state, similar to active + widgets.add(_buildNostrButton( + 'CANCEL', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), + )); + + return widgets; + + // Terminal states according to Mostro FSM case Status.expired: case Status.dispute: case Status.completedByAdmin: case Status.canceledByAdmin: case Status.settledByAdmin: case Status.canceled: - return [ - _buildCloseButton(context), - ]; + // No actions allowed in these terminal states + return []; } } + /// Helper method to build a NostrResponsiveButton with common properties + Widget _buildNostrButton( + String label, { + required WidgetRef ref, + required StateProvider completeProvider, + required StateProvider errorProvider, + required VoidCallback onPressed, + Color? backgroundColor, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: NostrResponsiveButton( + label: label, + buttonStyle: ButtonStyleType.raised, + width: 180, + height: 48, + completionProvider: completeProvider, + errorProvider: errorProvider, + onPressed: onPressed, + showSuccessIndicator: true, + timeout: const Duration(seconds: 30), + ), + ); + } + /// CONTACT Widget _buildContactButton(BuildContext context) { return ElevatedButton( diff --git a/lib/notifications/notification_service.dart b/lib/notifications/notification_service.dart index 01e934c6..0b414278 100644 --- a/lib/notifications/notification_service.dart +++ b/lib/notifications/notification_service.dart @@ -24,27 +24,11 @@ Future initializeNotifications() async { await plugin.initialize(initSettings); } -Future showNotification( - int id, String title, String body, String payload) async { - await flutterLocalNotificationsPlugin.show( - id, - title, - body, - NotificationDetails( - android: AndroidNotificationDetails( - 'mostro_channel', - 'Mostro Notifications', - importance: Importance.max, - )), - payload: payload, - ); -} - Future showLocalNotification(NostrEvent event) async { final notificationsPlugin = FlutterLocalNotificationsPlugin(); const details = NotificationDetails( android: AndroidNotificationDetails( - 'mostro_notifications', + 'mostro_channel', 'Mostro Notifications', importance: Importance.max, ), @@ -52,7 +36,7 @@ Future showLocalNotification(NostrEvent event) async { await notificationsPlugin.show( 0, 'New Mostro Event', - 'Action: ${event.kind}', + 'You have received a new message from Mostro', details, ); } diff --git a/lib/shared/widgets/nostr_responsive_button.dart b/lib/shared/widgets/nostr_responsive_button.dart index dcd7bd31..440aea9a 100644 --- a/lib/shared/widgets/nostr_responsive_button.dart +++ b/lib/shared/widgets/nostr_responsive_button.dart @@ -117,9 +117,12 @@ class _NostrResponsiveButtonState extends ConsumerState { void _showErrorSnackbar(String message) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + // Use a post-frame callback to avoid showing the SnackBar during build + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + }); } } @@ -160,19 +163,40 @@ class _NostrResponsiveButtonState extends ConsumerState { } } + @override + void didUpdateWidget(NostrResponsiveButton oldWidget) { + super.didUpdateWidget(oldWidget); + _checkForStateChanges(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _checkForStateChanges(); + } + + void _checkForStateChanges() { + // Use a post-frame callback to avoid state changes during build + WidgetsBinding.instance.addPostFrameCallback((_) { + // Check for completion + final isComplete = ref.read(widget.completionProvider); + if (isComplete && _loading && mounted) { + _handleCompletion(); + } + + // Check for errors + final error = ref.read(widget.errorProvider); + if (error != null && _loading && mounted) { + _handleError(error); + } + }); + } + @override Widget build(BuildContext context) { - // Listen to completion - final isComplete = ref.watch(widget.completionProvider); - if (isComplete && _loading) { - _handleCompletion(); - } - - // Listen to errors - final error = ref.watch(widget.errorProvider); - if (error != null && _loading) { - _handleError(error); - } + // Just watch the providers to rebuild when they change + ref.watch(widget.completionProvider); + ref.watch(widget.errorProvider); Widget childWidget; if (_loading) { diff --git a/lib/shared/widgets/responsive_button.dart b/lib/shared/widgets/responsive_button.dart deleted file mode 100644 index 69609fe0..00000000 --- a/lib/shared/widgets/responsive_button.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -enum ButtonStyleType { raised, outlined } - -class ResponsiveButton extends ConsumerStatefulWidget { - final String label; - final ButtonStyleType buttonStyle; - final VoidCallback onPressed; - final AsyncNotifierProvider>, AsyncValue> - listenTo; - final Duration timeout; - final String errorSnackbarMessage; - - const ResponsiveButton({ - super.key, - required this.label, - required this.buttonStyle, - required this.onPressed, - required this.listenTo, - this.timeout = const Duration(seconds: 5), - this.errorSnackbarMessage = 'Something went wrong.', - }); - - @override - ConsumerState createState() => _ResponsiveButtonState(); -} - -class _ResponsiveButtonState extends ConsumerState> { - bool _loading = false; - late final VoidCallback _listener; - - @override - void initState() { - super.initState(); - _listener = () { - final state = ref.read(widget.listenTo); - if (_loading) { - if (state is AsyncData) { - setState(() => _loading = false); - } else if (state is AsyncError) { - _showError(); - } - } - }; - } - - void _showError() { - setState(() => _loading = false); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(widget.errorSnackbarMessage)), - ); - } - } - - Future _handlePress() async { - setState(() => _loading = true); - widget.onPressed(); - - // Start timeout countdown - Future.delayed(widget.timeout, () { - final state = ref.read(widget.listenTo); - if (_loading && state is! AsyncData) { - _showError(); - } - }); - } - - @override - Widget build(BuildContext context) { - ref.listen(widget.listenTo, (_, __) => _listener()); - - final child = _loading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(widget.label); - - final button = switch (widget.buttonStyle) { - ButtonStyleType.raised => ElevatedButton( - onPressed: _loading ? null : _handlePress, - child: child, - ), - ButtonStyleType.outlined => OutlinedButton( - onPressed: _loading ? null : _handlePress, - child: child, - ), - }; - - return SizedBox(height: 48, child: button); - } -} From 051187e1f393cf93b9166809d4db5d1d4af16715 Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 26 Apr 2025 18:33:54 +1000 Subject: [PATCH 114/149] Refactor message handling to use Sembast storage streams instead of EventBus --- .../background_status_provider.dart | 3 - lib/data/repositories/base_storage.dart | 23 ++ lib/data/repositories/mostro_storage.dart | 86 ++++- .../notfiers/abstract_mostro_notifier.dart | 39 ++- .../order/notfiers/add_order_notifier.dart | 16 +- .../order/notfiers/cant_do_notifier.dart | 2 +- .../order/notfiers/dispute_notifier.dart | 2 +- .../order/notfiers/order_notifier.dart | 2 +- .../notfiers/payment_request_notifier.dart | 2 +- .../providers/order_notifier_provider.dart | 10 +- .../order/screens/add_order_screen.dart | 2 + lib/features/order/screens/error_screen.dart | 45 --- .../order/widgets/completion_message.dart | 51 --- lib/features/order/widgets/seller_info.dart | 37 --- .../trades/screens/trade_detail_screen.dart | 32 +- lib/services/currency_input_formatter.dart | 18 - lib/services/event_bus.dart | 22 -- lib/services/event_bus.g.dart | 26 -- lib/services/mostro_service.dart | 3 - .../providers/mostro_service_provider.g.dart | 2 +- .../providers/mostro_storage_provider.dart | 15 + lib/shared/providers/session_providers.dart | 12 - lib/shared/providers/session_providers.g.dart | 308 ------------------ lib/shared/widgets/memory_text_field.dart | 103 ------ .../nostr_responsive_button_example.dart | 134 -------- test/mocks.mocks.dart | 189 +++++------ test/services/mostro_service_test.mocks.dart | 95 +++++- 27 files changed, 364 insertions(+), 915 deletions(-) delete mode 100644 lib/background/background_status_provider.dart delete mode 100644 lib/features/order/screens/error_screen.dart delete mode 100644 lib/features/order/widgets/completion_message.dart delete mode 100644 lib/features/order/widgets/seller_info.dart delete mode 100644 lib/services/currency_input_formatter.dart delete mode 100644 lib/services/event_bus.dart delete mode 100644 lib/services/event_bus.g.dart delete mode 100644 lib/shared/providers/session_providers.g.dart delete mode 100644 lib/shared/widgets/memory_text_field.dart delete mode 100644 lib/shared/widgets/nostr_responsive_button_example.dart diff --git a/lib/background/background_status_provider.dart b/lib/background/background_status_provider.dart deleted file mode 100644 index 7eb544d2..00000000 --- a/lib/background/background_status_provider.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final isAppInForegroundProvider = StateProvider((_) => true); diff --git a/lib/data/repositories/base_storage.dart b/lib/data/repositories/base_storage.dart index 559e892c..7d05517f 100644 --- a/lib/data/repositories/base_storage.dart +++ b/lib/data/repositories/base_storage.dart @@ -105,6 +105,29 @@ abstract class BaseStorage { .toList()); } +Stream watchMessageForOrderId(String orderId) { + return store + .record(orderId) + .onSnapshot(db) + .map((snapshot) => snapshot?.value != null + ? fromDbMap(orderId, snapshot!.value) + : null); +} + +Stream> watchAllMessagesForOrderId(String orderId) { + final finder = Finder( + filter: Filter.equals('id', orderId), + sortOrders: [SortOrder('timestamp', false)] + ); + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots + .map((snapshot) => fromDbMap(orderId, snapshot.value)) + .toList()); +} + /// If needed, close or clean up resources here. void dispose() {} } diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index dfe26bf0..8fd24fd6 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -10,13 +10,31 @@ class MostroStorage extends BaseStorage { MostroStorage({required Database db}) : super(db, stringMapStoreFactory.store('orders')); + // Generate a unique key for each message + String generateMessageKey(MostroMessage message) { + // Use orderId + action + requestId/tradeIndex or current timestamp for uniqueness + final uniqueSuffix = message.requestId != null + ? message.requestId.toString() + : message.tradeIndex != null + ? message.tradeIndex.toString() + : DateTime.now().millisecondsSinceEpoch.toString(); + + return '${message.id}_${message.action.name}_$uniqueSuffix'; + } + + /// Save or update any MostroMessage Future addMessage(MostroMessage message) async { - final id = messageKey(message); + final id = generateMessageKey(message); try { - await putItem(id, message); + // Add metadata for easier querying + final Map dbMap = message.toJson(); + dbMap['payload_type'] = message.payload?.runtimeType.toString(); + dbMap['order_id'] = message.id; + + await store.record(id).put(db, dbMap); _logger.i( - 'Saved message of type ${message.payload.runtimeType} with id $id', + 'Saved message of type ${message.payload?.runtimeType} with id $id', ); } catch (e, stack) { _logger.e( @@ -141,4 +159,66 @@ class MostroStorage extends BaseStorage { messageKey(msg), ); } + + /// Get the latest message for an order, regardless of type + Future getLatestMessageById(String orderId) async { + final finder = Finder( + filter: Filter.equals('order_id', orderId), + sortOrders: [SortOrder('request_id', false)], + limit: 1 + ); + + final snapshot = await store.findFirst(db, finder: finder); + if (snapshot != null) { + return MostroMessage.fromJson(snapshot.value); + } + return null; + } + + /// Stream of the latest message for an order + Stream watchLatestMessage(String orderId) { + final finder = Finder( + filter: Filter.equals('order_id', orderId), + sortOrders: [SortOrder('request_id', false)], + limit: 1 + ); + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots.isNotEmpty + ? MostroMessage.fromJson(snapshots.first.value) + : null); + } + + /// Stream of all messages for an order + Stream> watchAllMessages(String orderId) { + final finder = Finder( + filter: Filter.equals('order_id', orderId), + sortOrders: [SortOrder('request_id', false)] + ); + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots + .map((snapshot) => MostroMessage.fromJson(snapshot.value)) + .toList()); + } + + /// Stream of messages filtered by requestId + Stream watchMessagesByRequestId(int requestId) { + final finder = Finder( + filter: Filter.equals('request_id', requestId), + sortOrders: [SortOrder('timestamp', false)], + limit: 1 + ); + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots.isNotEmpty + ? MostroMessage.fromJson(snapshots.first.value) + : null); + } } diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index b6be9bea..d4d9becb 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -2,27 +2,25 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; + import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/data/models/payload.dart'; import 'package:mostro_mobile/data/models/peer.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; +import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/navigation_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; -import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; -import 'package:mostro_mobile/shared/providers/session_providers.dart'; -class AbstractMostroNotifier - extends StateNotifier { +class AbstractMostroNotifier extends StateNotifier { final String orderId; final Ref ref; - ProviderSubscription>? subscription; + ProviderSubscription>? subscription; final logger = Logger(); AbstractMostroNotifier( @@ -32,19 +30,28 @@ class AbstractMostroNotifier Future sync() async { final storage = ref.read(mostroStorageProvider); - state = await storage.getMessageById(orderId) ?? state; + final latestMessage = await storage.getMessageById(orderId); + if (latestMessage != null) { + state = latestMessage; + } } void subscribe() { - subscription = ref.listen(sessionMessagesProvider(orderId), (_, next) { - next.when( - data: (msg) { - handleEvent(msg); - }, - error: (error, stack) => handleError(error, stack), - loading: () {}, - ); - }); + // Use the mostroMessageStream provider that directly watches Sembast storage changes + subscription = ref.listen( + mostroMessageStreamProvider(orderId), + (_, AsyncValue next) { + next.when( + data: (MostroMessage? msg) { + if (msg != null) { + handleEvent(msg); + } + }, + error: (error, stack) => handleError(error, stack), + loading: () {}, + ); + }, + ); } void handleError(Object err, StackTrace stack) { diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart index c9f4db26..5dc65d62 100644 --- a/lib/features/order/notfiers/add_order_notifier.dart +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -10,7 +10,7 @@ import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; -class AddOrderNotifier extends AbstractMostroNotifier { +class AddOrderNotifier extends AbstractMostroNotifier { late final MostroService mostroService; int? requestId; @@ -32,13 +32,15 @@ class AddOrderNotifier extends AbstractMostroNotifier { (_, next) { next.when( data: (msg) { - if (msg.payload is Order) { - state = msg; - if (msg.action == Action.newOrder) { - confirmOrder(msg); + if (msg != null) { + if (msg.payload is Order) { + state = msg; + if (msg.action == Action.newOrder) { + confirmOrder(msg); + } + } else if (msg.payload is CantDo) { + _handleCantDo(msg); } - } else if (msg.payload is CantDo) { - _handleCantDo(msg); } }, error: (error, stack) => handleError(error, stack), diff --git a/lib/features/order/notfiers/cant_do_notifier.dart b/lib/features/order/notfiers/cant_do_notifier.dart index 48c0129f..79d3b376 100644 --- a/lib/features/order/notfiers/cant_do_notifier.dart +++ b/lib/features/order/notfiers/cant_do_notifier.dart @@ -4,7 +4,7 @@ import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; import 'package:mostro_mobile/shared/providers/notification_notifier_provider.dart'; -class CantDoNotifier extends AbstractMostroNotifier { +class CantDoNotifier extends AbstractMostroNotifier { CantDoNotifier(super.orderId, super.ref) { sync(); subscribe(); diff --git a/lib/features/order/notfiers/dispute_notifier.dart b/lib/features/order/notfiers/dispute_notifier.dart index fa3618f0..59b3442b 100644 --- a/lib/features/order/notfiers/dispute_notifier.dart +++ b/lib/features/order/notfiers/dispute_notifier.dart @@ -2,7 +2,7 @@ import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; -class DisputeNotifier extends AbstractMostroNotifier { +class DisputeNotifier extends AbstractMostroNotifier { DisputeNotifier(super.orderId, super.ref) { sync(); subscribe(); diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 9c9a363d..4b3f6b46 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -6,7 +6,7 @@ import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.d import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; -class OrderNotifier extends AbstractMostroNotifier { +class OrderNotifier extends AbstractMostroNotifier { late final MostroService mostroService; OrderNotifier(super.orderId, super.ref) { diff --git a/lib/features/order/notfiers/payment_request_notifier.dart b/lib/features/order/notfiers/payment_request_notifier.dart index 487f5d74..97daa357 100644 --- a/lib/features/order/notfiers/payment_request_notifier.dart +++ b/lib/features/order/notfiers/payment_request_notifier.dart @@ -2,7 +2,7 @@ import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/payment_request.dart'; import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; -class PaymentRequestNotifier extends AbstractMostroNotifier { +class PaymentRequestNotifier extends AbstractMostroNotifier { PaymentRequestNotifier(super.orderId, super.ref) { sync(); subscribe(); diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index 96c28c61..0e845075 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -5,7 +5,7 @@ import 'package:mostro_mobile/features/order/notfiers/add_order_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/dispute_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/payment_request_notifier.dart'; -import 'package:mostro_mobile/services/event_bus.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/features/order/notfiers/cant_do_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'order_notifier_provider.g.dart'; @@ -72,11 +72,9 @@ class OrderTypeNotifier extends _$OrderTypeNotifier { void set(OrderType value) => state = value; } -final addOrderEventsProvider = StreamProvider.family( +final addOrderEventsProvider = StreamProvider.family( (ref, requestId) { - final bus = ref.read(eventBusProvider); - return bus.stream.where( - (msg) => msg.requestId == requestId, - ); + final storage = ref.watch(mostroStorageProvider); + return storage.watchMessagesByRequestId(requestId); }, ); diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 76a64650..b6cb35b8 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -532,6 +532,8 @@ Widget _buildActionButtons( (previous, next) { next.when( data: (message) { + if (message == null) return; + if (message.action == nostr_action.Action.newOrder && message.id != null) { // Order was successfully created ref.read(orderSubmissionCompleteProvider.notifier).state = true; diff --git a/lib/features/order/screens/error_screen.dart b/lib/features/order/screens/error_screen.dart deleted file mode 100644 index 279b4a92..00000000 --- a/lib/features/order/screens/error_screen.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; - -class ErrorScreen extends StatelessWidget { - final String errorMessage; - - const ErrorScreen({super.key, required this.errorMessage}); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.dark1, - appBar: OrderAppBar(title: 'Error'), - body: Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - errorMessage, - style: const TextStyle( - color: Colors.red, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - ElevatedButton( - onPressed: () => context.go('/'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.cream1, - ), - child: const Text('Return to Main Screen'), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/order/widgets/completion_message.dart b/lib/features/order/widgets/completion_message.dart deleted file mode 100644 index ce35b2e9..00000000 --- a/lib/features/order/widgets/completion_message.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; - -class CompletionMessage extends StatelessWidget { - final String message; - - const CompletionMessage({super.key, required this.message}); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.dark1, - appBar: OrderAppBar(title: 'Completion'), - 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: () => context.go('/'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('Return to Main Screen'), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/order/widgets/seller_info.dart b/lib/features/order/widgets/seller_info.dart deleted file mode 100644 index e9a05e2b..00000000 --- a/lib/features/order/widgets/seller_info.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; -import 'package:flutter/material.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/nostr_event.dart'; - -class SellerInfo extends StatelessWidget { - final NostrEvent order; - - const SellerInfo({super.key, required this.order}); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - const CircleAvatar( - backgroundColor: AppTheme.grey2, - foregroundImage: AssetImage('assets/images/launcher-icon.png'), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(order.name!, - style: const TextStyle( - color: AppTheme.cream1, fontWeight: FontWeight.bold)), - Text( - '${order.rating?.totalRating}/${order.rating?.maxRate} (${order.rating?.totalReviews})', - style: const TextStyle(color: AppTheme.mostroGreen), - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 81886adc..4379a202 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -13,6 +13,7 @@ import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/features/trades/widgets/mostro_message_detail_widget.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; @@ -189,7 +190,12 @@ class TradeDetailScreen extends ConsumerWidget { /// Following the Mostro protocol state machine for order flow. List _buildActionButtons( BuildContext context, WidgetRef ref, NostrEvent order) { - final message = ref.watch(orderNotifierProvider(orderId)); + // Using the new messageStateProvider to ensure we get the latest message state + final messageState = ref.watch(mostroMessageStreamProvider(orderId)); + final message = messageState.value ?? ref.watch(orderNotifierProvider(orderId)); + + // Default action if message is null + final currentAction = message?.action; final session = ref.watch(sessionProvider(orderId)); final userRole = session?.role; @@ -280,7 +286,7 @@ class TradeDetailScreen extends ConsumerWidget { final widgets = []; // Only show rate button if that action is available - if (message.action == actions.Action.rate) { + if (currentAction == actions.Action.rate) { widgets.add(_buildNostrButton( 'RATE', ref: ref, @@ -300,8 +306,8 @@ class TradeDetailScreen extends ConsumerWidget { // Role-specific actions according to FSM if (userRole == Role.buyer) { // FSM: Buyer can fiat-sent - if (message.action != actions.Action.fiatSentOk && - message.action != actions.Action.fiatSent) { + if (currentAction != actions.Action.fiatSentOk && + currentAction != actions.Action.fiatSent) { widgets.add(_buildNostrButton( 'FIAT SENT', ref: ref, @@ -326,9 +332,9 @@ class TradeDetailScreen extends ConsumerWidget { )); // FSM: Buyer can dispute - if (message.action != actions.Action.disputeInitiatedByYou && - message.action != actions.Action.disputeInitiatedByPeer && - message.action != actions.Action.dispute) { + if (currentAction != actions.Action.disputeInitiatedByYou && + currentAction != actions.Action.disputeInitiatedByPeer && + currentAction != actions.Action.dispute) { widgets.add(_buildNostrButton( 'DISPUTE', ref: ref, @@ -353,9 +359,9 @@ class TradeDetailScreen extends ConsumerWidget { )); // FSM: Seller can dispute - if (message.action != actions.Action.disputeInitiatedByYou && - message.action != actions.Action.disputeInitiatedByPeer && - message.action != actions.Action.dispute) { + if (currentAction != actions.Action.disputeInitiatedByYou && + currentAction != actions.Action.disputeInitiatedByPeer && + currentAction != actions.Action.dispute) { widgets.add(_buildNostrButton( 'DISPUTE', ref: ref, @@ -370,7 +376,7 @@ class TradeDetailScreen extends ConsumerWidget { } // Rate button if applicable (common for both roles) - if (message.action == actions.Action.rate) { + if (currentAction == actions.Action.rate) { widgets.add(_buildNostrButton( 'RATE', ref: ref, @@ -443,7 +449,7 @@ class TradeDetailScreen extends ConsumerWidget { final widgets = []; // Add confirm cancel if cooperative cancel was initiated by peer - if (message.action == actions.Action.cooperativeCancelInitiatedByPeer) { + if (currentAction == actions.Action.cooperativeCancelInitiatedByPeer) { widgets.add(_buildNostrButton( 'CONFIRM CANCEL', ref: ref, @@ -463,7 +469,7 @@ class TradeDetailScreen extends ConsumerWidget { final widgets = []; // FSM: Both roles can rate counterparty if not already rated - if (message.action != actions.Action.rateReceived) { + if (currentAction != actions.Action.rateReceived) { widgets.add(_buildNostrButton( 'RATE', ref: ref, diff --git a/lib/services/currency_input_formatter.dart b/lib/services/currency_input_formatter.dart deleted file mode 100644 index 699e508b..00000000 --- a/lib/services/currency_input_formatter.dart +++ /dev/null @@ -1,18 +0,0 @@ -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/event_bus.dart b/lib/services/event_bus.dart deleted file mode 100644 index 5e709503..00000000 --- a/lib/services/event_bus.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:async'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -part 'event_bus.g.dart'; - -class EventBus { - final _controller = StreamController.broadcast(); - - Stream get stream => _controller.stream; - - void emit(MostroMessage message) => _controller.add(message); - - void dispose() => _controller.close(); -} - -@riverpod -EventBus eventBus(Ref ref) { - final bus = EventBus(); - //ref.onDispose(bus.dispose); - return bus; -} diff --git a/lib/services/event_bus.g.dart b/lib/services/event_bus.g.dart deleted file mode 100644 index 73a6084a..00000000 --- a/lib/services/event_bus.g.dart +++ /dev/null @@ -1,26 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'event_bus.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$eventBusHash() => r'8e50dc963edbcc64d7f61e4054c5537782944acf'; - -/// See also [eventBus]. -@ProviderFor(eventBus) -final eventBusProvider = AutoDisposeProvider.internal( - eventBus, - name: r'eventBusProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$eventBusHash, - dependencies: null, - allTransitiveDependencies: null, -); - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -typedef EventBusRef = AutoDisposeProviderRef; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 2f74a042..bd18cf2d 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -1,11 +1,9 @@ import 'dart:convert'; import 'package:dart_nostr/nostr/model/export.dart'; -import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; -import 'package:mostro_mobile/services/event_bus.dart'; import 'package:mostro_mobile/services/lifecycle_manager.dart'; import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; @@ -79,7 +77,6 @@ class MostroService { session.orderId = msg.id; await _sessionNotifier.saveSession(session); } - ref.read(eventBusProvider).emit(msg); }); } diff --git a/lib/shared/providers/mostro_service_provider.g.dart b/lib/shared/providers/mostro_service_provider.g.dart index 5b7af983..a7683766 100644 --- a/lib/shared/providers/mostro_service_provider.g.dart +++ b/lib/shared/providers/mostro_service_provider.g.dart @@ -22,7 +22,7 @@ final eventStorageProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef EventStorageRef = AutoDisposeProviderRef; -String _$mostroServiceHash() => r'26300c32176dcefaeeaae02ec6932998060972fc'; +String _$mostroServiceHash() => r'2a8b4a5218fdb1eff4bdd385d3107475d0295a8b'; /// See also [mostroService]. @ProviderFor(mostroService) diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart index 1ccdfab0..15951a38 100644 --- a/lib/shared/providers/mostro_storage_provider.dart +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; @@ -6,3 +7,17 @@ final mostroStorageProvider = Provider((ref) { final mostroDatabase = ref.watch(mostroDatabaseProvider); return MostroStorage(db: mostroDatabase); }); + +final mostroMessageStreamProvider = StreamProvider.family( + (ref, orderId) { + final storage = ref.read(mostroStorageProvider); + return storage.watchLatestMessage(orderId); + }, +); + +final mostroMessageHistoryProvider = StreamProvider.family, String>( + (ref, orderId) { + final storage = ref.read(mostroStorageProvider); + return storage.watchAllMessages(orderId); + }, +); \ No newline at end of file diff --git a/lib/shared/providers/session_providers.dart b/lib/shared/providers/session_providers.dart index 322c76f2..ea4bba57 100644 --- a/lib/shared/providers/session_providers.dart +++ b/lib/shared/providers/session_providers.dart @@ -1,11 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; -import 'package:mostro_mobile/services/event_bus.dart'; import 'package:mostro_mobile/features/order/notfiers/cant_do_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/payment_request_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -part 'session_providers.g.dart'; class SessionProviders { final String orderId; @@ -27,15 +24,6 @@ class SessionProviders { } } -@riverpod -class SessionMessages extends _$SessionMessages { - @override - Stream build(String orderId) { - final bus = ref.watch(eventBusProvider); - return bus.stream.where((msg) => msg.id == orderId); - } -} - @riverpod SessionProviders sessionProviders(Ref ref, String orderId) { final providers = SessionProviders(orderId: orderId, ref: ref); diff --git a/lib/shared/providers/session_providers.g.dart b/lib/shared/providers/session_providers.g.dart deleted file mode 100644 index e0724638..00000000 --- a/lib/shared/providers/session_providers.g.dart +++ /dev/null @@ -1,308 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'session_providers.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$sessionProvidersHash() => r'7756994a4a75d695f0ea3377c0a3158155a46e50'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [sessionProviders]. -@ProviderFor(sessionProviders) -const sessionProvidersProvider = SessionProvidersFamily(); - -/// See also [sessionProviders]. -class SessionProvidersFamily extends Family { - /// See also [sessionProviders]. - const SessionProvidersFamily(); - - /// See also [sessionProviders]. - SessionProvidersProvider call( - String orderId, - ) { - return SessionProvidersProvider( - orderId, - ); - } - - @override - SessionProvidersProvider getProviderOverride( - covariant SessionProvidersProvider provider, - ) { - return call( - provider.orderId, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'sessionProvidersProvider'; -} - -/// See also [sessionProviders]. -class SessionProvidersProvider extends AutoDisposeProvider { - /// See also [sessionProviders]. - SessionProvidersProvider( - String orderId, - ) : this._internal( - (ref) => sessionProviders( - ref as SessionProvidersRef, - orderId, - ), - from: sessionProvidersProvider, - name: r'sessionProvidersProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$sessionProvidersHash, - dependencies: SessionProvidersFamily._dependencies, - allTransitiveDependencies: - SessionProvidersFamily._allTransitiveDependencies, - orderId: orderId, - ); - - SessionProvidersProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.orderId, - }) : super.internal(); - - final String orderId; - - @override - Override overrideWith( - SessionProviders Function(SessionProvidersRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: SessionProvidersProvider._internal( - (ref) => create(ref as SessionProvidersRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - orderId: orderId, - ), - ); - } - - @override - AutoDisposeProviderElement createElement() { - return _SessionProvidersProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is SessionProvidersProvider && other.orderId == orderId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, orderId.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin SessionProvidersRef on AutoDisposeProviderRef { - /// The parameter `orderId` of this provider. - String get orderId; -} - -class _SessionProvidersProviderElement - extends AutoDisposeProviderElement - with SessionProvidersRef { - _SessionProvidersProviderElement(super.provider); - - @override - String get orderId => (origin as SessionProvidersProvider).orderId; -} - -String _$sessionMessagesHash() => r'a8f6f4e0f8ec58f745b1e1acd68651436706d948'; - -abstract class _$SessionMessages - extends BuildlessAutoDisposeStreamNotifier { - late final String orderId; - - Stream build( - String orderId, - ); -} - -/// See also [SessionMessages]. -@ProviderFor(SessionMessages) -const sessionMessagesProvider = SessionMessagesFamily(); - -/// See also [SessionMessages]. -class SessionMessagesFamily extends Family> { - /// See also [SessionMessages]. - const SessionMessagesFamily(); - - /// See also [SessionMessages]. - SessionMessagesProvider call( - String orderId, - ) { - return SessionMessagesProvider( - orderId, - ); - } - - @override - SessionMessagesProvider getProviderOverride( - covariant SessionMessagesProvider provider, - ) { - return call( - provider.orderId, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'sessionMessagesProvider'; -} - -/// See also [SessionMessages]. -class SessionMessagesProvider extends AutoDisposeStreamNotifierProviderImpl< - SessionMessages, MostroMessage> { - /// See also [SessionMessages]. - SessionMessagesProvider( - String orderId, - ) : this._internal( - () => SessionMessages()..orderId = orderId, - from: sessionMessagesProvider, - name: r'sessionMessagesProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$sessionMessagesHash, - dependencies: SessionMessagesFamily._dependencies, - allTransitiveDependencies: - SessionMessagesFamily._allTransitiveDependencies, - orderId: orderId, - ); - - SessionMessagesProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.orderId, - }) : super.internal(); - - final String orderId; - - @override - Stream runNotifierBuild( - covariant SessionMessages notifier, - ) { - return notifier.build( - orderId, - ); - } - - @override - Override overrideWith(SessionMessages Function() create) { - return ProviderOverride( - origin: this, - override: SessionMessagesProvider._internal( - () => create()..orderId = orderId, - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - orderId: orderId, - ), - ); - } - - @override - AutoDisposeStreamNotifierProviderElement - createElement() { - return _SessionMessagesProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is SessionMessagesProvider && other.orderId == orderId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, orderId.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin SessionMessagesRef - on AutoDisposeStreamNotifierProviderRef { - /// The parameter `orderId` of this provider. - String get orderId; -} - -class _SessionMessagesProviderElement - extends AutoDisposeStreamNotifierProviderElement with SessionMessagesRef { - _SessionMessagesProviderElement(super.provider); - - @override - String get orderId => (origin as SessionMessagesProvider).orderId; -} -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/shared/widgets/memory_text_field.dart b/lib/shared/widgets/memory_text_field.dart deleted file mode 100644 index c0910600..00000000 --- a/lib/shared/widgets/memory_text_field.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; // Replace with your SharedPreferencesAsync import if needed - -class MemoryTextField extends StatefulWidget { - /// The label for the text field. - final String label; - /// A unique key string for persisting the history of inputs. - final String historyKey; - /// An optional callback that fires whenever the text changes. - final ValueChanged? onChanged; - - const MemoryTextField({ - super.key, - required this.label, - required this.historyKey, - this.onChanged, - }); - - @override - MemoryTextFieldState createState() => MemoryTextFieldState(); -} - -class MemoryTextFieldState extends State { - final TextEditingController _controller = TextEditingController(); - // In-memory list for storing previously entered values. - List _history = []; - - @override - void initState() { - super.initState(); - _loadHistory(); - } - - Future _loadHistory() async { - final prefs = await SharedPreferences.getInstance(); - final historyJson = prefs.getString(widget.historyKey); - if (historyJson != null) { - final List list = jsonDecode(historyJson); - setState(() { - _history = list.cast(); - }); - } - } - - Future _saveHistory() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(widget.historyKey, jsonEncode(_history)); - } - - void _handleSubmitted(String value) { - if (value.isNotEmpty && !_history.contains(value)) { - setState(() { - _history.add(value); - }); - _saveHistory(); - } - } - - @override - Widget build(BuildContext context) { - return Autocomplete( - optionsBuilder: (TextEditingValue textEditingValue) { - if (textEditingValue.text.isEmpty) { - return const Iterable.empty(); - } - return _history.where((String option) => - option.toLowerCase().contains(textEditingValue.text.toLowerCase())); - }, - onSelected: (String selection) { - _controller.text = selection; - if (widget.onChanged != null) { - widget.onChanged!(selection); - } - }, - fieldViewBuilder: (BuildContext context, - TextEditingController fieldTextEditingController, - FocusNode fieldFocusNode, - VoidCallback onFieldSubmitted) { - // Synchronize the controller values. - _controller.value = fieldTextEditingController.value; - return TextFormField( - controller: fieldTextEditingController, - focusNode: fieldFocusNode, - decoration: InputDecoration( - labelText: widget.label, - ), - onChanged: widget.onChanged, - onFieldSubmitted: (value) { - _handleSubmitted(value); - onFieldSubmitted(); - }, - ); - }, - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } -} diff --git a/lib/shared/widgets/nostr_responsive_button_example.dart b/lib/shared/widgets/nostr_responsive_button_example.dart deleted file mode 100644 index 266df462..00000000 --- a/lib/shared/widgets/nostr_responsive_button_example.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mostro_mobile/shared/widgets/nostr_responsive_button.dart'; - -// Example providers for tracking operation state -final orderOperationCompleteProvider = StateProvider((ref) => false); -final orderOperationErrorProvider = StateProvider((ref) => null); - -class NostrResponsiveButtonExample extends ConsumerWidget { - final String orderId; - - const NostrResponsiveButtonExample({ - super.key, - required this.orderId, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - appBar: AppBar(title: const Text('Example Order Screen')), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Order details would go here - const SizedBox(height: 24), - - // Example of take order button - NostrResponsiveButton( - label: 'Take Order', - buttonStyle: ButtonStyleType.raised, - completionProvider: orderOperationCompleteProvider, - errorProvider: orderOperationErrorProvider, - onPressed: () { - // This is where you call your order notifier - _takeOrder(ref, context); - }, - onOperationComplete: () { - // Navigate when complete - optional, could also be handled in the notifier - context.push('/order-success/$orderId'); - }, - timeout: const Duration(seconds: 30), - ), - - const SizedBox(height: 16), - - // Example of cancel order button - NostrResponsiveButton( - label: 'Cancel Order', - buttonStyle: ButtonStyleType.outlined, - completionProvider: orderOperationCompleteProvider, - errorProvider: orderOperationErrorProvider, - onPressed: () { - _cancelOrder(ref, context); - }, - // Red text for cancel button - width: double.infinity, - ), - ], - ), - ), - ); - } - - void _takeOrder(WidgetRef ref, BuildContext context) { - // 1. Call your order notifier's method - final orderNotifier = ref.read(orderNotifierProvider); - orderNotifier.takeOrder(orderId); - - // 2. Set up a listener in your notifier or service to update the completion state - // This would be done in your OrderNotifier's implementation: - - /* - Future takeOrder(String orderId) async { - try { - // Start the process of taking the order via Nostr - final order = await _nostrService.takeOrder(orderId); - - // Listen for confirmation events (this would depend on your Nostr implementation) - _subscription = _nostrService.subscribeToOrderUpdates(orderId).listen((event) { - if (event.status == 'accepted') { - // Signal that the operation completed successfully - ref.read(orderOperationCompleteProvider.notifier).state = true; - } else if (event.status == 'error') { - // Signal that there was an error - ref.read(orderOperationErrorProvider.notifier).state = event.errorMessage; - } - }); - } catch (e) { - // Handle initial errors - ref.read(orderOperationErrorProvider.notifier).state = e.toString(); - } - } - */ - } - - void _cancelOrder(WidgetRef ref, BuildContext context) { - // Similar to takeOrder but for cancellation - - // In a real implementation, you would: - // 1. Call your cancel order method - // 2. Set up listeners for success/failure - // 3. Update the state providers accordingly - - // This is just a simulation for the example - Future.delayed(const Duration(seconds: 2), () { - // Simulate success - ref.read(orderOperationCompleteProvider.notifier).state = true; - }); - } -} - -// Stand-in for your actual order notifier provider -final orderNotifierProvider = Provider((ref) { - return OrderNotifier(ref); -}); - -// Simple stand-in for your actual OrderNotifier class -class OrderNotifier { - final Ref ref; - - OrderNotifier(this.ref); - - Future takeOrder(String orderId) async { - // Implementation would go here - // After getting a result, update the state providers - } - - Future cancelOrder(String orderId) async { - // Implementation would go here - } -} diff --git a/test/mocks.mocks.dart b/test/mocks.mocks.dart index ac0941b1..e67cf929 100644 --- a/test/mocks.mocks.dart +++ b/test/mocks.mocks.dart @@ -3,18 +3,16 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i6; +import 'dart:async' as _i5; -import 'package:dart_nostr/nostr/model/event/event.dart' as _i9; +import 'package:dart_nostr/nostr/model/export.dart' as _i8; import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:mostro_mobile/background/abstract_background_service.dart' - as _i3; -import 'package:mostro_mobile/data/models.dart' as _i4; +import 'package:mostro_mobile/data/models.dart' as _i3; import 'package:mostro_mobile/data/repositories/open_orders_repository.dart' - as _i8; -import 'package:mostro_mobile/features/settings/settings.dart' as _i7; -import 'package:mostro_mobile/services/mostro_service.dart' as _i5; + as _i7; +import 'package:mostro_mobile/features/settings/settings.dart' as _i6; +import 'package:mostro_mobile/services/mostro_service.dart' as _i4; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -41,19 +39,8 @@ class _FakeRef_0 extends _i1.SmartFake ); } -class _FakeBackgroundService_1 extends _i1.SmartFake - implements _i3.BackgroundService { - _FakeBackgroundService_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeSession_2 extends _i1.SmartFake implements _i4.Session { - _FakeSession_2( +class _FakeSession_1 extends _i1.SmartFake implements _i3.Session { + _FakeSession_1( Object parent, Invocation parentInvocation, ) : super( @@ -65,7 +52,7 @@ class _FakeSession_2 extends _i1.SmartFake implements _i4.Session { /// A class which mocks [MostroService]. /// /// See the documentation for Mockito's code generation for more information. -class MockMostroService extends _i1.Mock implements _i5.MostroService { +class MockMostroService extends _i1.Mock implements _i4.MostroService { MockMostroService() { _i1.throwOnMissingStub(this); } @@ -80,26 +67,16 @@ class MockMostroService extends _i1.Mock implements _i5.MostroService { ) as _i2.Ref); @override - _i3.BackgroundService get backgroundService => (super.noSuchMethod( - Invocation.getter(#backgroundService), - returnValue: _FakeBackgroundService_1( - this, - Invocation.getter(#backgroundService), - ), - ) as _i3.BackgroundService); - - @override - _i6.Future init() => (super.noSuchMethod( + void init() => super.noSuchMethod( Invocation.method( #init, [], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValueForMissingStub: null, + ); @override - void subscribe(_i4.Session? session) => super.noSuchMethod( + void subscribe(_i3.Session? session) => super.noSuchMethod( Invocation.method( #subscribe, [session], @@ -108,25 +85,25 @@ class MockMostroService extends _i1.Mock implements _i5.MostroService { ); @override - _i4.Session? getSessionByOrderId(String? orderId) => + _i3.Session? getSessionByOrderId(String? orderId) => (super.noSuchMethod(Invocation.method( #getSessionByOrderId, [orderId], - )) as _i4.Session?); + )) as _i3.Session?); @override - _i6.Future submitOrder(_i4.MostroMessage<_i4.Payload>? order) => + _i5.Future submitOrder(_i3.MostroMessage<_i3.Payload>? order) => (super.noSuchMethod( Invocation.method( #submitOrder, [order], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future takeBuyOrder( + _i5.Future takeBuyOrder( String? orderId, int? amount, ) => @@ -138,12 +115,12 @@ class MockMostroService extends _i1.Mock implements _i5.MostroService { amount, ], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future takeSellOrder( + _i5.Future takeSellOrder( String? orderId, int? amount, String? lnAddress, @@ -157,12 +134,12 @@ class MockMostroService extends _i1.Mock implements _i5.MostroService { lnAddress, ], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future sendInvoice( + _i5.Future sendInvoice( String? orderId, String? invoice, int? amount, @@ -176,52 +153,52 @@ class MockMostroService extends _i1.Mock implements _i5.MostroService { amount, ], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future cancelOrder(String? orderId) => (super.noSuchMethod( + _i5.Future cancelOrder(String? orderId) => (super.noSuchMethod( Invocation.method( #cancelOrder, [orderId], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future sendFiatSent(String? orderId) => (super.noSuchMethod( + _i5.Future sendFiatSent(String? orderId) => (super.noSuchMethod( Invocation.method( #sendFiatSent, [orderId], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future releaseOrder(String? orderId) => (super.noSuchMethod( + _i5.Future releaseOrder(String? orderId) => (super.noSuchMethod( Invocation.method( #releaseOrder, [orderId], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future disputeOrder(String? orderId) => (super.noSuchMethod( + _i5.Future disputeOrder(String? orderId) => (super.noSuchMethod( Invocation.method( #disputeOrder, [orderId], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future submitRating( + _i5.Future submitRating( String? orderId, int? rating, ) => @@ -233,28 +210,28 @@ class MockMostroService extends _i1.Mock implements _i5.MostroService { rating, ], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future<_i4.Session> publishOrder(_i4.MostroMessage<_i4.Payload>? order) => + _i5.Future<_i3.Session> publishOrder(_i3.MostroMessage<_i3.Payload>? order) => (super.noSuchMethod( Invocation.method( #publishOrder, [order], ), - returnValue: _i6.Future<_i4.Session>.value(_FakeSession_2( + returnValue: _i5.Future<_i3.Session>.value(_FakeSession_1( this, Invocation.method( #publishOrder, [order], ), )), - ) as _i6.Future<_i4.Session>); + ) as _i5.Future<_i3.Session>); @override - void updateSettings(_i7.Settings? settings) => super.noSuchMethod( + void updateSettings(_i6.Settings? settings) => super.noSuchMethod( Invocation.method( #updateSettings, [settings], @@ -267,16 +244,16 @@ class MockMostroService extends _i1.Mock implements _i5.MostroService { /// /// See the documentation for Mockito's code generation for more information. class MockOpenOrdersRepository extends _i1.Mock - implements _i8.OpenOrdersRepository { + implements _i7.OpenOrdersRepository { MockOpenOrdersRepository() { _i1.throwOnMissingStub(this); } @override - _i6.Stream> get eventsStream => (super.noSuchMethod( + _i5.Stream> get eventsStream => (super.noSuchMethod( Invocation.getter(#eventsStream), - returnValue: _i6.Stream>.empty(), - ) as _i6.Stream>); + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); @override void dispose() => super.noSuchMethod( @@ -288,60 +265,70 @@ class MockOpenOrdersRepository extends _i1.Mock ); @override - _i6.Future<_i9.NostrEvent?> getOrderById(String? orderId) => + _i5.Future<_i8.NostrEvent?> getOrderById(String? orderId) => (super.noSuchMethod( Invocation.method( #getOrderById, [orderId], ), - returnValue: _i6.Future<_i9.NostrEvent?>.value(), - ) as _i6.Future<_i9.NostrEvent?>); + returnValue: _i5.Future<_i8.NostrEvent?>.value(), + ) as _i5.Future<_i8.NostrEvent?>); @override - _i6.Future addOrder(_i9.NostrEvent? order) => (super.noSuchMethod( + _i5.Future addOrder(_i8.NostrEvent? order) => (super.noSuchMethod( Invocation.method( #addOrder, [order], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future deleteOrder(String? orderId) => (super.noSuchMethod( + _i5.Future deleteOrder(String? orderId) => (super.noSuchMethod( Invocation.method( #deleteOrder, [orderId], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future> getAllOrders() => (super.noSuchMethod( + _i5.Future> getAllOrders() => (super.noSuchMethod( Invocation.method( #getAllOrders, [], ), - returnValue: _i6.Future>.value(<_i9.NostrEvent>[]), - ) as _i6.Future>); + returnValue: _i5.Future>.value(<_i8.NostrEvent>[]), + ) as _i5.Future>); @override - _i6.Future updateOrder(_i9.NostrEvent? order) => (super.noSuchMethod( + _i5.Future updateOrder(_i8.NostrEvent? order) => (super.noSuchMethod( Invocation.method( #updateOrder, [order], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - void updateSettings(_i7.Settings? settings) => super.noSuchMethod( + void updateSettings(_i6.Settings? settings) => super.noSuchMethod( Invocation.method( #updateSettings, [settings], ), returnValueForMissingStub: null, ); + + @override + _i5.Future reloadData() => (super.noSuchMethod( + Invocation.method( + #reloadData, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); } diff --git a/test/services/mostro_service_test.mocks.dart b/test/services/mostro_service_test.mocks.dart index d2480050..b9501017 100644 --- a/test/services/mostro_service_test.mocks.dart +++ b/test/services/mostro_service_test.mocks.dart @@ -191,11 +191,11 @@ class MockNostrService extends _i1.Mock implements _i8.NostrService { ) as _i9.Future>); @override - _i9.Stream<_i3.NostrEvent> subscribeToEvents(_i3.NostrFilter? filter) => + _i9.Stream<_i3.NostrEvent> subscribeToEvents(_i3.NostrRequest? request) => (super.noSuchMethod( Invocation.method( #subscribeToEvents, - [filter], + [request], ), returnValue: _i9.Stream<_i3.NostrEvent>.empty(), ) as _i9.Stream<_i3.NostrEvent>); @@ -398,6 +398,15 @@ class MockNostrService extends _i1.Mock implements _i8.NostrService { ), )), ) as _i9.Future<_i3.NostrEvent>); + + @override + void unsubscribe(String? id) => super.noSuchMethod( + Invocation.method( + #unsubscribe, + [id], + ), + returnValueForMissingStub: null, + ); } /// A class which mocks [SessionNotifier]. @@ -637,6 +646,22 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { ), ) as _i5.StoreRef>); + @override + String generateMessageKey(_i7.MostroMessage<_i6.Payload>? message) => + (super.noSuchMethod( + Invocation.method( + #generateMessageKey, + [message], + ), + returnValue: _i11.dummyValue( + this, + Invocation.method( + #generateMessageKey, + [message], + ), + ), + ) as String); + @override _i9.Future addMessage(_i7.MostroMessage<_i6.Payload>? message) => (super.noSuchMethod( @@ -797,6 +822,50 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { returnValue: _i9.Future.value(false), ) as _i9.Future); + @override + _i9.Future<_i7.MostroMessage<_i6.Payload>?> getLatestMessageById( + String? orderId) => + (super.noSuchMethod( + Invocation.method( + #getLatestMessageById, + [orderId], + ), + returnValue: _i9.Future<_i7.MostroMessage<_i6.Payload>?>.value(), + ) as _i9.Future<_i7.MostroMessage<_i6.Payload>?>); + + @override + _i9.Stream<_i7.MostroMessage<_i6.Payload>?> watchLatestMessage( + String? orderId) => + (super.noSuchMethod( + Invocation.method( + #watchLatestMessage, + [orderId], + ), + returnValue: _i9.Stream<_i7.MostroMessage<_i6.Payload>?>.empty(), + ) as _i9.Stream<_i7.MostroMessage<_i6.Payload>?>); + + @override + _i9.Stream>> watchAllMessages( + String? orderId) => + (super.noSuchMethod( + Invocation.method( + #watchAllMessages, + [orderId], + ), + returnValue: _i9.Stream>>.empty(), + ) as _i9.Stream>>); + + @override + _i9.Stream<_i7.MostroMessage<_i6.Payload>?> watchMessagesByRequestId( + int? requestId) => + (super.noSuchMethod( + Invocation.method( + #watchMessagesByRequestId, + [requestId], + ), + returnValue: _i9.Stream<_i7.MostroMessage<_i6.Payload>?>.empty(), + ) as _i9.Stream<_i7.MostroMessage<_i6.Payload>?>); + @override _i9.Future putItem( String? id, @@ -888,6 +957,28 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { returnValue: _i9.Stream>>.empty(), ) as _i9.Stream>>); + @override + _i9.Stream<_i7.MostroMessage<_i6.Payload>?> watchMessageForOrderId( + String? orderId) => + (super.noSuchMethod( + Invocation.method( + #watchMessageForOrderId, + [orderId], + ), + returnValue: _i9.Stream<_i7.MostroMessage<_i6.Payload>?>.empty(), + ) as _i9.Stream<_i7.MostroMessage<_i6.Payload>?>); + + @override + _i9.Stream>> watchAllMessagesForOrderId( + String? orderId) => + (super.noSuchMethod( + Invocation.method( + #watchAllMessagesForOrderId, + [orderId], + ), + returnValue: _i9.Stream>>.empty(), + ) as _i9.Stream>>); + @override void dispose() => super.noSuchMethod( Invocation.method( From 534e75242381d097e164f4f0b1fbd2e146412ff3 Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 26 Apr 2025 19:35:06 +1000 Subject: [PATCH 115/149] Add stream providers and watch methods for sessions and messages --- lib/data/repositories/event_storage.dart | 43 +++++++++++++ lib/data/repositories/session_storage.dart | 39 ++++++++++++ .../providers/mostro_storage_provider.dart | 8 +++ .../providers/order_repository_provider.dart | 4 +- lib/shared/providers/session_providers.dart | 60 ++++++++++++++++++- .../providers/session_storage_provider.dart | 22 ++++++- 6 files changed, 172 insertions(+), 4 deletions(-) diff --git a/lib/data/repositories/event_storage.dart b/lib/data/repositories/event_storage.dart index a345452f..956423b9 100644 --- a/lib/data/repositories/event_storage.dart +++ b/lib/data/repositories/event_storage.dart @@ -39,4 +39,47 @@ class EventStorage extends BaseStorage { Map toDbMap(NostrEvent event) { return event.toMap(); } + + /// Stream of all events for a query + Stream> watchAll({Filter? filter}) { + final finder = filter != null ? Finder(filter: filter) : null; + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots + .map((snapshot) => fromDbMap(snapshot.key, snapshot.value)) + .toList()); + } + + /// Stream of the latest event matching a query + Stream watchLatest({Filter? filter, List? sortOrders}) { + final finder = Finder( + filter: filter, + sortOrders: sortOrders ?? [SortOrder('created_at', false)], + limit: 1 + ); + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots.isNotEmpty + ? fromDbMap(snapshots.first.key, snapshots.first.value) + : null); + } + + /// Stream of events filtered by event ID + Stream watchById(String eventId) { + final finder = Finder( + filter: Filter.equals('id', eventId), + limit: 1 + ); + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots.isNotEmpty + ? fromDbMap(snapshots.first.key, snapshots.first.value) + : null); + } } diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index e131f550..d5226f2e 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -71,4 +71,43 @@ class SessionStorage extends BaseStorage { return now.difference(startTime).inHours >= sessionExpirationHours; }, maxBatchSize: maxBatchSize); } + + /// Watch a session by ID and get a stream of updates + Stream watchSession(String orderId) { + return store + .record(orderId) + .onSnapshot(db) + .map((snapshot) => snapshot != null + ? fromDbMap(snapshot.key, snapshot.value) + : null); + } + + /// Watch all sessions and get a stream of updates + Stream> watchAllSessions({Filter? filter}) { + final finder = filter != null ? Finder(filter: filter) : null; + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots + .map((snapshot) => fromDbMap(snapshot.key, snapshot.value)) + .toList()); + } + + /// Watch active sessions by comparing with current time + Stream> watchActiveSessions(int sessionExpirationHours) { + final now = DateTime.now(); + final expirationTime = now.subtract(Duration(hours: sessionExpirationHours)); + + // Filter for sessions that have startTime newer than expirationTime + final filter = Filter.greaterThan('start_time', expirationTime.millisecondsSinceEpoch); + final finder = Finder(filter: filter, sortOrders: [SortOrder('start_time', false)]); + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots + .map((snapshot) => fromDbMap(snapshot.key, snapshot.value)) + .toList()); + } } diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart index 15951a38..9f4d8fc3 100644 --- a/lib/shared/providers/mostro_storage_provider.dart +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -20,4 +20,12 @@ final mostroMessageHistoryProvider = StreamProvider.family, final storage = ref.read(mostroStorageProvider); return storage.watchAllMessages(orderId); }, +); + +// New provider for watching messages by request ID +final mostroMessagesByRequestIdProvider = StreamProvider.family( + (ref, requestId) { + final storage = ref.read(mostroStorageProvider); + return storage.watchMessagesByRequestId(requestId); + }, ); \ No newline at end of file diff --git a/lib/shared/providers/order_repository_provider.dart b/lib/shared/providers/order_repository_provider.dart index e772ceb8..1f58ad78 100644 --- a/lib/shared/providers/order_repository_provider.dart +++ b/lib/shared/providers/order_repository_provider.dart @@ -25,12 +25,12 @@ final orderEventsProvider = StreamProvider>((ref) { }); final eventProvider = Provider.family((ref, orderId) { - final allEventsAsync = ref.watch(orderEventsProvider); final allEvents = allEventsAsync.maybeWhen( data: (data) => data, orElse: () => [], ); // firstWhereOrNull returns null if no match is found - return allEvents.firstWhereOrNull((evt) => (evt as NostrEvent).orderId == orderId); + return allEvents + .firstWhereOrNull((evt) => (evt as NostrEvent).orderId == orderId); }); diff --git a/lib/shared/providers/session_providers.dart b/lib/shared/providers/session_providers.dart index ea4bba57..6fae1b41 100644 --- a/lib/shared/providers/session_providers.dart +++ b/lib/shared/providers/session_providers.dart @@ -1,7 +1,12 @@ +import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/cant_do_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/payment_request_notifier.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; +import 'package:mostro_mobile/shared/providers/session_storage_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; class SessionProviders { @@ -27,7 +32,60 @@ class SessionProviders { @riverpod SessionProviders sessionProviders(Ref ref, String orderId) { final providers = SessionProviders(orderId: orderId, ref: ref); - //ref.onDispose(providers.dispose); + ref.onDispose(providers.dispose); return providers; } +/// Stream provider for watching both session and message state together +@riverpod +Stream sessionWithMessages(Ref ref, String orderId) { + // Create a StreamController to emit combined data + final controller = StreamController(); + + // Get both streams + final sessionStream = ref.watch(sessionProvider(orderId).stream); + final messagesStream = ref.watch(mostroMessageHistoryProvider(orderId).stream); + + // Track latest values + Session? latestSession; + List latestMessages = []; + + // Subscribe to both streams + final sessionSubscription = sessionStream.listen((session) { + latestSession = session; + controller.add(SessionWithMessages( + session: latestSession, + messages: latestMessages, + )); + }); + + final messagesSubscription = messagesStream.listen((messages) { + latestMessages = messages; + controller.add(SessionWithMessages( + session: latestSession, + messages: latestMessages, + )); + }); + + // Clean up subscriptions when the stream is closed + ref.onDispose(() { + sessionSubscription.cancel(); + messagesSubscription.cancel(); + controller.close(); + }); + + return controller.stream; +} + +/// A combined class for session and its messages +class SessionWithMessages { + final Session? session; + final List messages; + + SessionWithMessages({this.session, required this.messages}); + + bool get hasSession => session != null; + bool get hasMessages => messages.isNotEmpty; + MostroMessage? get latestMessage => messages.isNotEmpty ? messages.first : null; +} + diff --git a/lib/shared/providers/session_storage_provider.dart b/lib/shared/providers/session_storage_provider.dart index 0b3c26dd..44ec1de3 100644 --- a/lib/shared/providers/session_storage_provider.dart +++ b/lib/shared/providers/session_storage_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/session_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; @@ -6,5 +7,24 @@ import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; final sessionStorageProvider = Provider((ref) { final keyManager = ref.read(keyManagerProvider); final database = ref.read(mostroDatabaseProvider); - return SessionStorage(db: database, keyManager); + return SessionStorage(keyManager, db: database); +}); + +/// Stream provider for watching a single session by ID +final sessionProvider = StreamProvider.family((ref, orderId) { + final storage = ref.read(sessionStorageProvider); + return storage.watchSession(orderId); +}); + +/// Stream provider for watching all sessions +final allSessionsProvider = StreamProvider>((ref) { + final storage = ref.read(sessionStorageProvider); + return storage.watchAllSessions(); +}); + +/// Stream provider for watching active/non-expired sessions +final activeSessionsProvider = StreamProvider>((ref) { + final storage = ref.read(sessionStorageProvider); + // Default to 48 hours as session expiration time + return storage.watchActiveSessions(48); }); From f8e61a209ec6d108d64b93c74cb26eebb78083a0 Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 26 Apr 2025 20:43:43 +1000 Subject: [PATCH 116/149] Refactor storage and session providers, add error handling and remove unused code --- lib/data/repositories/mostro_storage.dart | 92 ++++++++++++------- .../repositories/open_orders_repository.dart | 2 +- lib/data/repositories/session_storage.dart | 39 -------- .../providers/mostro_storage_provider.dart | 8 -- lib/shared/providers/session_providers.dart | 60 +----------- .../providers/session_storage_provider.dart | 20 ---- pubspec.lock | 8 ++ pubspec.yaml | 1 + 8 files changed, 68 insertions(+), 162 deletions(-) diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 8fd24fd6..1bcbf448 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -177,48 +177,70 @@ class MostroStorage extends BaseStorage { /// Stream of the latest message for an order Stream watchLatestMessage(String orderId) { - final finder = Finder( - filter: Filter.equals('order_id', orderId), - sortOrders: [SortOrder('request_id', false)], - limit: 1 - ); - - return store - .query(finder: finder) - .onSnapshots(db) - .map((snapshots) => snapshots.isNotEmpty - ? MostroMessage.fromJson(snapshots.first.value) - : null); + // Use try-catch to handle any database errors gracefully + try { + // Sort by ID (descending) which should correlate to insertion order + final finder = Finder( + filter: Filter.equals('order_id', orderId), + // ID is always available and unique, so use that for sorting + sortOrders: [SortOrder(Field.key, false)], + limit: 1 + ); + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots.isNotEmpty + ? MostroMessage.fromJson(snapshots.first.value) + : null); + } catch (e) { + // Return an empty stream that completes immediately + return Stream.value(null); + } } /// Stream of all messages for an order Stream> watchAllMessages(String orderId) { - final finder = Finder( - filter: Filter.equals('order_id', orderId), - sortOrders: [SortOrder('request_id', false)] - ); - - return store - .query(finder: finder) - .onSnapshots(db) - .map((snapshots) => snapshots - .map((snapshot) => MostroMessage.fromJson(snapshot.value)) - .toList()); + try { + // Sort by ID (descending) which should correlate to insertion order + final finder = Finder( + filter: Filter.equals('order_id', orderId), + // ID is always available and unique + sortOrders: [SortOrder(Field.key, false)] + ); + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots + .map((snapshot) => MostroMessage.fromJson(snapshot.value)) + .toList()); + } catch (e) { + // Return an empty list stream that completes immediately + return Stream.value([]); + } } /// Stream of messages filtered by requestId + /// This method is special purpose - solely for initial exchange tracking Stream watchMessagesByRequestId(int requestId) { - final finder = Finder( - filter: Filter.equals('request_id', requestId), - sortOrders: [SortOrder('timestamp', false)], - limit: 1 - ); - - return store - .query(finder: finder) - .onSnapshots(db) - .map((snapshots) => snapshots.isNotEmpty - ? MostroMessage.fromJson(snapshots.first.value) - : null); + try { + final finder = Finder( + filter: Filter.equals('request_id', requestId), + limit: 1 + ); + + return store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots.isNotEmpty + ? MostroMessage.fromJson(snapshots.first.value) + : null); + } catch (e) { + // Return an empty stream that completes immediately + return Stream.value(null); + } } + + } diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index eceef90c..60990291 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -25,6 +25,7 @@ class OpenOrdersRepository implements OrderRepository { NostrEvent? get mostroInstance => _mostroInstance; OpenOrdersRepository(this._nostrService, this._settings) { + // Subscribe to orders and initialize data _subscribeToOrders(); } @@ -118,5 +119,4 @@ class OpenOrdersRepository implements OrderRepository { // Then resubscribe for future updates _subscribeToOrders(); } - } diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index d5226f2e..e131f550 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -71,43 +71,4 @@ class SessionStorage extends BaseStorage { return now.difference(startTime).inHours >= sessionExpirationHours; }, maxBatchSize: maxBatchSize); } - - /// Watch a session by ID and get a stream of updates - Stream watchSession(String orderId) { - return store - .record(orderId) - .onSnapshot(db) - .map((snapshot) => snapshot != null - ? fromDbMap(snapshot.key, snapshot.value) - : null); - } - - /// Watch all sessions and get a stream of updates - Stream> watchAllSessions({Filter? filter}) { - final finder = filter != null ? Finder(filter: filter) : null; - - return store - .query(finder: finder) - .onSnapshots(db) - .map((snapshots) => snapshots - .map((snapshot) => fromDbMap(snapshot.key, snapshot.value)) - .toList()); - } - - /// Watch active sessions by comparing with current time - Stream> watchActiveSessions(int sessionExpirationHours) { - final now = DateTime.now(); - final expirationTime = now.subtract(Duration(hours: sessionExpirationHours)); - - // Filter for sessions that have startTime newer than expirationTime - final filter = Filter.greaterThan('start_time', expirationTime.millisecondsSinceEpoch); - final finder = Finder(filter: filter, sortOrders: [SortOrder('start_time', false)]); - - return store - .query(finder: finder) - .onSnapshots(db) - .map((snapshots) => snapshots - .map((snapshot) => fromDbMap(snapshot.key, snapshot.value)) - .toList()); - } } diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart index 9f4d8fc3..15951a38 100644 --- a/lib/shared/providers/mostro_storage_provider.dart +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -20,12 +20,4 @@ final mostroMessageHistoryProvider = StreamProvider.family, final storage = ref.read(mostroStorageProvider); return storage.watchAllMessages(orderId); }, -); - -// New provider for watching messages by request ID -final mostroMessagesByRequestIdProvider = StreamProvider.family( - (ref, requestId) { - final storage = ref.read(mostroStorageProvider); - return storage.watchMessagesByRequestId(requestId); - }, ); \ No newline at end of file diff --git a/lib/shared/providers/session_providers.dart b/lib/shared/providers/session_providers.dart index 6fae1b41..ea4bba57 100644 --- a/lib/shared/providers/session_providers.dart +++ b/lib/shared/providers/session_providers.dart @@ -1,12 +1,7 @@ -import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; -import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/order/notfiers/order_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/cant_do_notifier.dart'; import 'package:mostro_mobile/features/order/notfiers/payment_request_notifier.dart'; -import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; -import 'package:mostro_mobile/shared/providers/session_storage_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; class SessionProviders { @@ -32,60 +27,7 @@ class SessionProviders { @riverpod SessionProviders sessionProviders(Ref ref, String orderId) { final providers = SessionProviders(orderId: orderId, ref: ref); - ref.onDispose(providers.dispose); + //ref.onDispose(providers.dispose); return providers; } -/// Stream provider for watching both session and message state together -@riverpod -Stream sessionWithMessages(Ref ref, String orderId) { - // Create a StreamController to emit combined data - final controller = StreamController(); - - // Get both streams - final sessionStream = ref.watch(sessionProvider(orderId).stream); - final messagesStream = ref.watch(mostroMessageHistoryProvider(orderId).stream); - - // Track latest values - Session? latestSession; - List latestMessages = []; - - // Subscribe to both streams - final sessionSubscription = sessionStream.listen((session) { - latestSession = session; - controller.add(SessionWithMessages( - session: latestSession, - messages: latestMessages, - )); - }); - - final messagesSubscription = messagesStream.listen((messages) { - latestMessages = messages; - controller.add(SessionWithMessages( - session: latestSession, - messages: latestMessages, - )); - }); - - // Clean up subscriptions when the stream is closed - ref.onDispose(() { - sessionSubscription.cancel(); - messagesSubscription.cancel(); - controller.close(); - }); - - return controller.stream; -} - -/// A combined class for session and its messages -class SessionWithMessages { - final Session? session; - final List messages; - - SessionWithMessages({this.session, required this.messages}); - - bool get hasSession => session != null; - bool get hasMessages => messages.isNotEmpty; - MostroMessage? get latestMessage => messages.isNotEmpty ? messages.first : null; -} - diff --git a/lib/shared/providers/session_storage_provider.dart b/lib/shared/providers/session_storage_provider.dart index 44ec1de3..04fa321b 100644 --- a/lib/shared/providers/session_storage_provider.dart +++ b/lib/shared/providers/session_storage_provider.dart @@ -1,5 +1,4 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/session_storage.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; @@ -9,22 +8,3 @@ final sessionStorageProvider = Provider((ref) { final database = ref.read(mostroDatabaseProvider); return SessionStorage(keyManager, db: database); }); - -/// Stream provider for watching a single session by ID -final sessionProvider = StreamProvider.family((ref, orderId) { - final storage = ref.read(sessionStorageProvider); - return storage.watchSession(orderId); -}); - -/// Stream provider for watching all sessions -final allSessionsProvider = StreamProvider>((ref) { - final storage = ref.read(sessionStorageProvider); - return storage.watchAllSessions(); -}); - -/// Stream provider for watching active/non-expired sessions -final activeSessionsProvider = StreamProvider>((ref) { - final storage = ref.read(sessionStorageProvider); - // Default to 48 hours as session expiration time - return storage.watchActiveSessions(48); -}); diff --git a/pubspec.lock b/pubspec.lock index 3106eb76..94dfe2ea 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1162,6 +1162,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + rxdart: + dependency: "direct main" + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" sembast: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 91269a99..f686ff77 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -83,6 +83,7 @@ dependencies: flutter_background_service: ^5.1.0 system_tray: ^2.0.3 path_provider: ^2.1.5 + rxdart: ^0.28.0 dev_dependencies: flutter_test: From b9f8e13062b6979258a6413bab81befa2b63075c Mon Sep 17 00:00:00 2001 From: Biz Date: Sat, 26 Apr 2025 22:41:43 +1000 Subject: [PATCH 117/149] Add auto-reset to button and improve order request ID generation for uniqueness --- .../order/notfiers/add_order_notifier.dart | 16 +- .../screens/add_lightning_invoice_screen.dart | 12 +- .../order/screens/add_order_screen.dart | 167 +++++++++--------- .../trades/screens/trade_detail_screen.dart | 11 +- .../widgets/mostro_message_detail_widget.dart | 18 +- .../widgets/nostr_responsive_button.dart | 11 ++ 6 files changed, 130 insertions(+), 105 deletions(-) diff --git a/lib/features/order/notfiers/add_order_notifier.dart b/lib/features/order/notfiers/add_order_notifier.dart index 5dc65d62..7a1d235a 100644 --- a/lib/features/order/notfiers/add_order_notifier.dart +++ b/lib/features/order/notfiers/add_order_notifier.dart @@ -12,15 +12,19 @@ import 'package:mostro_mobile/shared/providers/notification_notifier_provider.da class AddOrderNotifier extends AbstractMostroNotifier { late final MostroService mostroService; - int? requestId; + late int requestId; AddOrderNotifier(super.orderId, super.ref) { mostroService = ref.read(mostroServiceProvider); - requestId = BigInt.parse( - orderId.replaceAll('-', ''), - radix: 16, - ).toUnsigned(64).toInt(); + // Generate a unique requestId from the orderId but with better uniqueness + // Take a portion of the UUID and combine with current timestamp to ensure uniqueness + final uuid = orderId.replaceAll('-', ''); + final timestamp = DateTime.now().microsecondsSinceEpoch; + + // Use only the first 8 chars of UUID combined with current timestamp for uniqueness + // This avoids potential collisions from truncation while keeping values in int range + requestId = (int.parse(uuid.substring(0, 8), radix: 16) ^ timestamp) & 0x7FFFFFFF; subscribe(); } @@ -28,7 +32,7 @@ class AddOrderNotifier extends AbstractMostroNotifier { @override void subscribe() { subscription = ref.listen( - addOrderEventsProvider(requestId!), + addOrderEventsProvider(requestId), (_, next) { next.when( data: (msg) { diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index bec80646..c8bbaf41 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart'; class AddLightningInvoiceScreen extends ConsumerStatefulWidget { @@ -23,9 +24,10 @@ class _AddLightningInvoiceScreenState @override Widget build(BuildContext context) { - final order = ref.read(orderNotifierProvider(widget.orderId)); + final orderId = widget.orderId; + final order = ref.watch(eventProvider(orderId)); - final amount = order.getPayload()?.amount; + final amount = order?.amount; return Scaffold( backgroundColor: AppTheme.dark1, @@ -49,7 +51,7 @@ class _AddLightningInvoiceScreenState .read(orderNotifierProvider(widget.orderId).notifier); try { await orderNotifier.sendInvoice( - widget.orderId, invoice, amount); + widget.orderId, invoice, int.parse(amount)); context.go('/'); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( @@ -76,7 +78,7 @@ class _AddLightningInvoiceScreenState ); } }, - amount: amount!, + amount: int.parse(amount!), ), ), ), diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index b6cb35b8..4df15825 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -8,6 +8,7 @@ import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as nostr_action; 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'; import 'package:mostro_mobile/features/order/widgets/fixed_switch_widget.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; @@ -16,9 +17,12 @@ import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/widgets/nostr_responsive_button.dart'; import 'package:uuid/uuid.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; -final orderSubmissionCompleteProvider = StateProvider((ref) => false); -final orderSubmissionErrorProvider = StateProvider((ref) => null); +// Create a direct state provider tied to the order action/status +final orderActionStatusProvider = Provider.family, int>( + (ref, requestId) => ref.watch(addOrderEventsProvider(requestId)) +); class AddOrderScreen extends ConsumerStatefulWidget { const AddOrderScreen({super.key}); @@ -441,49 +445,67 @@ class _AddOrderScreenState extends ConsumerState { /// /// ACTION BUTTONS /// -Widget _buildActionButtons( - BuildContext context, WidgetRef ref, OrderType orderType) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - TextButton( - onPressed: () { - context.pop(); - }, - child: const Text('CANCEL'), - ), - const SizedBox(width: 8.0), - NostrResponsiveButton( - label: 'SUBMIT', - buttonStyle: ButtonStyleType.raised, - width: 120, - // Reference the state providers we created - completionProvider: orderSubmissionCompleteProvider, - errorProvider: orderSubmissionErrorProvider, - onPressed: () { - if (_formKey.currentState?.validate() ?? false) { - _submitOrder(context, ref, orderType); - } - }, - // Navigate when the operation completes - onOperationComplete: () { - // Navigate to the orders screen or detail screen - context.go('/'); // Or navigate to a more specific page - }, - // Show a success indicator briefly before navigating - showSuccessIndicator: true, - ), - ], - ); -} + // Track the current request ID for the button state providers + int? _currentRequestId; + + Widget _buildActionButtons( + BuildContext context, WidgetRef ref, OrderType orderType) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: () { + context.pop(); + }, + child: const Text('CANCEL'), + ), + const SizedBox(width: 8.0), + NostrResponsiveButton( + label: 'SUBMIT', + buttonStyle: ButtonStyleType.raised, + width: 120, + // Use an explicit provider that responds to the latest order message state + completionProvider: StateProvider((ref) { + final requestId = _currentRequestId; // Track the current request ID + if (requestId == null) return false; + + // Check if we have a completed order message + final messageState = ref.watch(addOrderEventsProvider(requestId)); + return messageState.whenOrNull( + data: (msg) => msg?.action == nostr_action.Action.newOrder && msg?.id != null, + ) ?? false; + }), + // Error provider based on order action status + errorProvider: StateProvider((ref) { + final requestId = _currentRequestId; + if (requestId == null) return null; + + // Check for CantDo response + final messageState = ref.watch(addOrderEventsProvider(requestId)); + return messageState.whenOrNull( + data: (msg) => msg?.payload is CantDo + ? (msg?.getPayload()?.cantDoReason.toString() ?? 'Failed to create order') + : null, + ); + }), + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + _submitOrder(context, ref, orderType); + } + }, + timeout: const Duration(seconds: 5), // Short timeout for better UX + showSuccessIndicator: true, + ), + ], + ); + } + /// /// SUBMIT ORDER /// void _submitOrder(BuildContext context, WidgetRef ref, OrderType orderType) { - // Reset state providers - ref.read(orderSubmissionCompleteProvider.notifier).state = false; - ref.read(orderSubmissionErrorProvider.notifier).state = null; - + // No need to reset state providers as they're now derived from the order state + final selectedFiatCode = ref.read(selectedFiatCodeProvider); try { @@ -495,10 +517,12 @@ Widget _buildActionButtons( ); // Calculate the request ID the same way AddOrderNotifier does - final requestId = BigInt.parse( - tempOrderId.replaceAll('-', ''), - radix: 16, - ).toUnsigned(64).toInt(); + final requestId = notifier.requestId; + + // Store the current request ID for the button state providers + setState(() { + _currentRequestId = requestId; + }); final fiatAmount = _maxFiatAmount != null ? 0 : _minFiatAmount; final minAmount = _maxFiatAmount != null ? _minFiatAmount : null; @@ -526,43 +550,24 @@ Widget _buildActionButtons( // Submit the order first notifier.submitOrder(order); - // Then listen for events - ref.listen( - addOrderEventsProvider(requestId), - (previous, next) { - next.when( - data: (message) { - if (message == null) return; - - if (message.action == nostr_action.Action.newOrder && message.id != null) { - // Order was successfully created - ref.read(orderSubmissionCompleteProvider.notifier).state = true; - } else if (message.payload is CantDo) { - // Order creation failed with a CantDo response - final cantDo = message.getPayload(); - ref.read(orderSubmissionErrorProvider.notifier).state = - cantDo?.cantDoReason.toString() ?? 'Failed to create order'; - } - }, - error: (error, stack) { - ref.read(orderSubmissionErrorProvider.notifier).state = error.toString(); - }, - loading: () {} - ); - }, - ); - - // Set a timeout for the operation - Future.delayed(const Duration(seconds: 20), () { - if (!ref.read(orderSubmissionCompleteProvider) && - ref.read(orderSubmissionErrorProvider) == null) { - // We can't cancel the listener, but we can update the UI - ref.read(orderSubmissionErrorProvider.notifier).state = 'Operation timed out'; - } - }); + // The timeout is now handled by the NostrResponsiveButton } catch (e) { - // If there's an exception, update the error provider - ref.read(orderSubmissionErrorProvider.notifier).state = e.toString(); + // If there's an exception, show an error dialog instead of using providers + if (context.mounted) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Error'), + content: Text(e.toString()), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } } } } diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 4379a202..a34e18a5 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -192,8 +192,9 @@ class TradeDetailScreen extends ConsumerWidget { BuildContext context, WidgetRef ref, NostrEvent order) { // Using the new messageStateProvider to ensure we get the latest message state final messageState = ref.watch(mostroMessageStreamProvider(orderId)); - final message = messageState.value ?? ref.watch(orderNotifierProvider(orderId)); - + final message = + messageState.value ?? ref.watch(orderNotifierProvider(orderId)); + // Default action if message is null final currentAction = message?.action; final session = ref.watch(sessionProvider(orderId)); @@ -306,7 +307,7 @@ class TradeDetailScreen extends ConsumerWidget { // Role-specific actions according to FSM if (userRole == Role.buyer) { // FSM: Buyer can fiat-sent - if (currentAction != actions.Action.fiatSentOk && + if (currentAction != actions.Action.fiatSentOk && currentAction != actions.Action.fiatSent) { widgets.add(_buildNostrButton( 'FIAT SENT', @@ -387,6 +388,10 @@ class TradeDetailScreen extends ConsumerWidget { )); } + widgets.add( + _buildContactButton(context), + ); + return widgets; case Status.fiatSent: diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index 111e6de1..b40ef1ca 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -78,21 +78,19 @@ class MostroMessageDetail extends ConsumerWidget { actionText = S.of(context)!.buyerInvoiceAccepted; break; case actions.Action.holdInvoicePaymentAccepted: - final payload = mostroMessage.getPayload(); actionText = S.of(context)!.holdInvoicePaymentAccepted( - payload!.fiatAmount, - payload.fiatCode, - payload.paymentMethod, - payload.sellerTradePubkey ?? session!.peer!.publicKey, + order.fiatAmount, + order.currency!, + order.paymentMethods.firstOrNull ?? '', + session!.peer?.publicKey ?? '', ); break; case actions.Action.buyerTookOrder: - final payload = mostroMessage.getPayload(); actionText = S.of(context)!.buyerTookOrder( - payload!.buyerTradePubkey ?? session!.peer!.publicKey, - payload.fiatCode, - payload.fiatAmount, - payload.paymentMethod, + session!.peer?.publicKey ?? '', + order.currency!, + order.fiatAmount, + order.paymentMethods.firstOrNull ?? '', ); break; case actions.Action.fiatSentOk: diff --git a/lib/shared/widgets/nostr_responsive_button.dart b/lib/shared/widgets/nostr_responsive_button.dart index 440aea9a..e3a8dfe5 100644 --- a/lib/shared/widgets/nostr_responsive_button.dart +++ b/lib/shared/widgets/nostr_responsive_button.dart @@ -161,6 +161,17 @@ class _NostrResponsiveButtonState extends ConsumerState { } else { _showErrorSnackbar(error); } + + // Auto-reset after a delay to allow for retries + Future.delayed(const Duration(milliseconds: 1500), () { + if (mounted) { + // Reset the button's internal state + setState(() { + _loading = false; + _showSuccess = false; + }); + } + }); } @override From 5e4382b3d9308ae638dc8cbc36cc5a6c0448ad4d Mon Sep 17 00:00:00 2001 From: Biz Date: Sun, 27 Apr 2025 08:17:18 +1000 Subject: [PATCH 118/149] Fix order stream to emit cached events immediately and handle empty states --- .../repositories/open_orders_repository.dart | 20 ++++++++++++++++++- .../home/providers/home_order_providers.dart | 1 - 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 60990291..17de8dc8 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -27,6 +27,8 @@ class OpenOrdersRepository implements OrderRepository { OpenOrdersRepository(this._nostrService, this._settings) { // Subscribe to orders and initialize data _subscribeToOrders(); + // Immediately emit current (possibly empty) cache so UI doesn't remain in loading state + _eventStreamController.add(_events.values.toList()); } /// Subscribes to events matching the given filter. @@ -57,6 +59,11 @@ class OpenOrdersRepository implements OrderRepository { }, onError: (error) { _logger.e('Error in order subscription: $error'); }); + + // Ensure listeners receive at least one snapshot right after (re)subscription + if (!_eventStreamController.isClosed) { + _eventStreamController.add(_events.values.toList()); + } } @override @@ -71,7 +78,14 @@ class OpenOrdersRepository implements OrderRepository { return Future.value(_events[orderId]); } - Stream> get eventsStream => _eventStreamController.stream; + // Stream that immediately emits current cache to every new listener before + // forwarding live updates. + Stream> get eventsStream async* { + // Emit cached events synchronously. + yield _events.values.toList(); + // Forward subsequent updates. + yield* _eventStreamController.stream; + } @override Future addOrder(NostrEvent order) { @@ -118,5 +132,9 @@ class OpenOrdersRepository implements OrderRepository { _events.clear(); // Then resubscribe for future updates _subscribeToOrders(); + // Emit empty list so UI updates immediately + if (!_eventStreamController.isClosed) { + _eventStreamController.add([]); + } } } diff --git a/lib/features/home/providers/home_order_providers.dart b/lib/features/home/providers/home_order_providers.dart index 89cff259..d9aa1c30 100644 --- a/lib/features/home/providers/home_order_providers.dart +++ b/lib/features/home/providers/home_order_providers.dart @@ -6,7 +6,6 @@ import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; - final homeOrderTypeProvider = StateProvider((ref) => OrderType.sell); final filteredOrdersProvider = Provider>((ref) { From ab34fe026c75320b530ce12e35e6adbe60f4770f Mon Sep 17 00:00:00 2001 From: Biz Date: Sun, 27 Apr 2025 09:59:46 +1000 Subject: [PATCH 119/149] Refactor storage repositories to use base class and improve message handling --- lib/data/repositories/base_storage.dart | 119 ++++++++++++++---- lib/data/repositories/mostro_storage.dart | 70 +++-------- lib/data/repositories/session_storage.dart | 15 +++ .../order/notfiers/order_notifier.dart | 7 +- .../providers/order_notifier_provider.dart | 16 ++- .../widgets/mostro_message_detail_widget.dart | 1 - 6 files changed, 145 insertions(+), 83 deletions(-) diff --git a/lib/data/repositories/base_storage.dart b/lib/data/repositories/base_storage.dart index 7d05517f..b2339b18 100644 --- a/lib/data/repositories/base_storage.dart +++ b/lib/data/repositories/base_storage.dart @@ -105,28 +105,105 @@ abstract class BaseStorage { .toList()); } -Stream watchMessageForOrderId(String orderId) { - return store - .record(orderId) - .onSnapshot(db) - .map((snapshot) => snapshot?.value != null - ? fromDbMap(orderId, snapshot!.value) - : null); -} + /// Watch a single item by ID with immediate value emission + Stream watchById(String id) async* { + // Emit current value immediately + yield await getItem(id); + + try { + yield* store + .record(id) + .onSnapshot(db) + .map((snapshot) => snapshot?.value != null + ? fromDbMap(id, snapshot!.value) + : null); + } catch (e) { + yield* Stream.value(null); + } + } -Stream> watchAllMessagesForOrderId(String orderId) { - final finder = Finder( - filter: Filter.equals('id', orderId), - sortOrders: [SortOrder('timestamp', false)] - ); - - return store - .query(finder: finder) - .onSnapshots(db) - .map((snapshots) => snapshots - .map((snapshot) => fromDbMap(orderId, snapshot.value)) - .toList()); -} + /// Watch all items with immediate value emission + Stream> watchAll() async* { + // Emit current values immediately + yield await getAllItems(); + + try { + yield* store.query().onSnapshots(db).map((snapshot) => snapshot + .map( + (record) => fromDbMap(record.key, record.value), + ) + .toList()); + } catch (e) { + yield* Stream.value([]); + } + } + + /// Watch items filtered by a specific field with immediate value emission + Stream> watchByField(String field, dynamic value) async* { + // Emit current values immediately + final finder = Finder( + filter: Filter.equals(field, value), + ); + yield await getAllItems(); + + try { + yield* store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots + .map((snapshot) => fromDbMap(snapshot.key, snapshot.value)) + .toList()); + } catch (e) { + yield* Stream.value([]); + } + } + + /// Watch items filtered by a specific field with sorting + Stream> watchByFieldSorted(String field, dynamic value, String sortField, bool descending) async* { + // Emit current values immediately + final finder = Finder( + filter: Filter.equals(field, value), + sortOrders: [SortOrder(sortField, descending)], + ); + yield await getAllItems(); + + try { + yield* store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots + .map((snapshot) => fromDbMap(snapshot.key, snapshot.value)) + .toList()); + } catch (e) { + yield* Stream.value([]); + } + } + + /// Watch a message by request ID with immediate value emission + Stream watchMessageByRequestId(int requestId) async* { + // Emit current value immediately + final finder = Finder( + filter: Filter.equals('request_id', requestId), + limit: 1 + ); + final snapshot = await store.findFirst(db, finder: finder); + if (snapshot != null) { + yield fromDbMap(snapshot.key, snapshot.value); + } else { + yield null; + } + + try { + yield* store + .query(finder: finder) + .onSnapshots(db) + .map((snapshots) => snapshots.isNotEmpty + ? fromDbMap(snapshots.first.key, snapshots.first.value) + : null); + } catch (e) { + yield* Stream.value(null); + } + } /// If needed, close or clean up resources here. void dispose() {} diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 1bcbf448..5fac5507 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -164,7 +164,6 @@ class MostroStorage extends BaseStorage { Future getLatestMessageById(String orderId) async { final finder = Finder( filter: Filter.equals('order_id', orderId), - sortOrders: [SortOrder('request_id', false)], limit: 1 ); @@ -177,70 +176,29 @@ class MostroStorage extends BaseStorage { /// Stream of the latest message for an order Stream watchLatestMessage(String orderId) { - // Use try-catch to handle any database errors gracefully - try { - // Sort by ID (descending) which should correlate to insertion order - final finder = Finder( - filter: Filter.equals('order_id', orderId), - // ID is always available and unique, so use that for sorting - sortOrders: [SortOrder(Field.key, false)], - limit: 1 - ); - - return store - .query(finder: finder) - .onSnapshots(db) - .map((snapshots) => snapshots.isNotEmpty - ? MostroMessage.fromJson(snapshots.first.value) - : null); - } catch (e) { - // Return an empty stream that completes immediately - return Stream.value(null); - } + return watchById(orderId); } /// Stream of all messages for an order Stream> watchAllMessages(String orderId) { - try { - // Sort by ID (descending) which should correlate to insertion order - final finder = Finder( - filter: Filter.equals('order_id', orderId), - // ID is always available and unique - sortOrders: [SortOrder(Field.key, false)] - ); - - return store - .query(finder: finder) - .onSnapshots(db) - .map((snapshots) => snapshots - .map((snapshot) => MostroMessage.fromJson(snapshot.value)) - .toList()); - } catch (e) { - // Return an empty list stream that completes immediately - return Stream.value([]); - } + return watchByFieldSorted('id', orderId, 'timestamp', false); } /// Stream of messages filtered by requestId /// This method is special purpose - solely for initial exchange tracking Stream watchMessagesByRequestId(int requestId) { - try { - final finder = Finder( - filter: Filter.equals('request_id', requestId), - limit: 1 - ); - - return store - .query(finder: finder) - .onSnapshots(db) - .map((snapshots) => snapshots.isNotEmpty - ? MostroMessage.fromJson(snapshots.first.value) - : null); - } catch (e) { - // Return an empty stream that completes immediately - return Stream.value(null); - } + return watchMessageByRequestId(requestId); } - + Future> getAllMessagesForId(String orderId) async { + final finder = Finder( + filter: Filter.equals('order_id', orderId), + sortOrders: [SortOrder(Field.key, false)] + ); + + final snapshots = await store.find(db, finder: finder); + return snapshots + .map((snapshot) => MostroMessage.fromJson(snapshot.value)) + .toList(); + } } diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index e131f550..f3b67922 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -71,4 +71,19 @@ class SessionStorage extends BaseStorage { return now.difference(startTime).inHours >= sessionExpirationHours; }, maxBatchSize: maxBatchSize); } + + /// Watch a single session by ID with immediate value emission + Stream watchSession(String sessionId) => watchById(sessionId); + + /// Watch all sessions with immediate value emission + Stream> watchAllSessions() => watchAll(); + + /// Watch sessions filtered by a specific field with immediate value emission + Stream> watchSessionsByField(String field, dynamic value) => + watchByField(field, value); + + /// Watch sessions filtered by a specific field with sorting + Stream> watchSessionsByFieldSorted( + String field, dynamic value, String sortField, bool descending) => + watchByFieldSorted(field, value, sortField, descending); } diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 4b3f6b46..5957a4e2 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -18,10 +18,9 @@ class OrderNotifier extends AbstractMostroNotifier { @override void handleEvent(MostroMessage event) { - if (event.payload is Order || event.payload == null) { - state = event; - handleOrderUpdate(); - } + // Forward all messages so UI reacts to CantDo, Peer, PaymentRequest, etc. + state = event; + handleOrderUpdate(); } Future submitOrder(Order order) async { diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index 0e845075..60c18323 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -75,6 +75,20 @@ class OrderTypeNotifier extends _$OrderTypeNotifier { final addOrderEventsProvider = StreamProvider.family( (ref, requestId) { final storage = ref.watch(mostroStorageProvider); - return storage.watchMessagesByRequestId(requestId); + return storage.watchMessageByRequestId(requestId); + }, +); + +final orderMessageStreamProvider = StreamProvider.family( + (ref, orderId) { + final storage = ref.watch(mostroStorageProvider); + return storage.watchLatestMessage(orderId); + }, +); + +final orderMessagesStreamProvider = StreamProvider.family, String>( + (ref, orderId) { + final storage = ref.watch(mostroStorageProvider); + return storage.watchAllMessages(orderId); }, ); diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index b40ef1ca..e25db673 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -7,7 +7,6 @@ import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/enums/cant_do_reason.dart'; import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; -import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; From a027e54233cc63a4115d2e37b69ca02ff01e0886 Mon Sep 17 00:00:00 2001 From: Biz Date: Sun, 27 Apr 2025 11:00:14 +1000 Subject: [PATCH 120/149] Add FSM-based order status tracking and update UI to use canonical status provider --- lib/core/mostro_fsm.dart | 93 +++++++++++++++++++ .../notfiers/abstract_mostro_notifier.dart | 16 +++- .../providers/order_status_provider.dart | 25 +++++ .../trades/screens/trade_detail_screen.dart | 31 +++---- .../widgets/mostro_message_detail_widget.dart | 12 ++- 5 files changed, 151 insertions(+), 26 deletions(-) create mode 100644 lib/core/mostro_fsm.dart create mode 100644 lib/features/order/providers/order_status_provider.dart diff --git a/lib/core/mostro_fsm.dart b/lib/core/mostro_fsm.dart new file mode 100644 index 00000000..d213daa8 --- /dev/null +++ b/lib/core/mostro_fsm.dart @@ -0,0 +1,93 @@ +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; + +/// Finite-State-Machine helper for Mostro order lifecycles. +/// +/// This table was generated directly from the authoritative +/// specification sent by the Mostro team. Only *state–transition → +/// next-state* information is encoded here. All auxiliary / neutral +/// notifications intentionally map to the **same** state so that +/// `nextStatus` always returns a non-null value. +class MostroFSM { + MostroFSM._(); + + /// Nested map: *currentStatus → { action → nextStatus }*. + static final Map> _transitions = { + // ───────────────────────── MATCHING / TAKING ──────────────────────── + Status.pending: { + Action.takeSell: Status.waitingBuyerInvoice, + Action.takeBuy: Status.waitingBuyerInvoice, // invoice presence handled elsewhere + Action.cancel: Status.canceled, + Action.disputeInitiatedByYou: Status.dispute, + Action.disputeInitiatedByPeer: Status.dispute, + }, + + // ───────────────────────── INVOICING ──────────────────────────────── + Status.waitingBuyerInvoice: { + Action.addInvoice: Status.waitingPayment, + Action.cancel: Status.canceled, + Action.disputeInitiatedByYou: Status.dispute, + Action.disputeInitiatedByPeer: Status.dispute, + }, + + // ───────────────────────── HOLD INVOICE PAYMENT ──────────────────── + Status.waitingPayment: { + Action.payInvoice: Status.active, + Action.holdInvoicePaymentAccepted: Status.active, + Action.holdInvoicePaymentCanceled: Status.canceled, + Action.cancel: Status.canceled, + Action.disputeInitiatedByYou: Status.dispute, + Action.disputeInitiatedByPeer: Status.dispute, + }, + + // ───────────────────────── ACTIVE TRADE ──────────────────────────── + Status.active: { + Action.fiatSent: Status.fiatSent, + Action.cooperativeCancelInitiatedByYou: Status.cooperativelyCanceled, + Action.cooperativeCancelInitiatedByPeer: Status.cooperativelyCanceled, + Action.cancel: Status.canceled, + Action.disputeInitiatedByYou: Status.dispute, + Action.disputeInitiatedByPeer: Status.dispute, + }, + + // ───────────────────────── AFTER FIAT SENT ───────────────────────── + Status.fiatSent: { + Action.release: Status.settledHoldInvoice, + Action.holdInvoicePaymentSettled: Status.settledHoldInvoice, + Action.cooperativeCancelInitiatedByYou: Status.cooperativelyCanceled, + Action.cooperativeCancelInitiatedByPeer: Status.cooperativelyCanceled, + Action.cancel: Status.canceled, + Action.disputeInitiatedByYou: Status.dispute, + Action.disputeInitiatedByPeer: Status.dispute, + }, + + // ───────────────────────── AFTER HOLD INVOICE SETTLED ────────────── + Status.settledHoldInvoice: { + Action.purchaseCompleted: Status.success, + Action.disputeInitiatedByYou: Status.dispute, + Action.disputeInitiatedByPeer: Status.dispute, + }, + + // ───────────────────────── DISPUTE BRANCH ────────────────────────── + Status.dispute: { + Action.adminSettle: Status.settledByAdmin, + Action.adminSettled: Status.settledByAdmin, + Action.adminCancel: Status.canceledByAdmin, + Action.adminCanceled: Status.canceledByAdmin, + }, + }; + + /// Returns the next `Status` after applying [action] to [current]. + /// If the action does **not** cause a state change, the same status + /// is returned. This makes it easier to call without additional + /// null-checking. + static Status nextStatus(Status? current, Action action) { + // Note: Initial state handled externally — we start from `pending` when the + // very first `newOrder` message arrives, so there is no `Status.start` + // entry here. + // If current is null (unknown), treat as pending so that first transition + // works for historical messages. + final safeCurrent = current ?? Status.pending; + return _transitions[safeCurrent]?[action] ?? safeCurrent; + } +} diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index d4d9becb..2b892512 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -2,12 +2,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/data/models/cant_do.dart'; - import 'package:mostro_mobile/data/models/dispute.dart'; import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/models/peer.dart'; +import 'package:mostro_mobile/core/mostro_fsm.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; @@ -23,6 +24,11 @@ class AbstractMostroNotifier extends StateNotifier { ProviderSubscription>? subscription; final logger = Logger(); + // Keep local FSM state in sync with every incoming MostroMessage. + Status _currentStatus = Status.pending; + + Status get currentStatus => _currentStatus; + AbstractMostroNotifier( this.orderId, this.ref, @@ -33,6 +39,9 @@ class AbstractMostroNotifier extends StateNotifier { final latestMessage = await storage.getMessageById(orderId); if (latestMessage != null) { state = latestMessage; + // Bootstrap FSM status from the order payload if present. + final orderPayload = latestMessage.getPayload(); + _currentStatus = orderPayload?.status ?? _currentStatus; } } @@ -59,7 +68,12 @@ class AbstractMostroNotifier extends StateNotifier { } void handleEvent(MostroMessage event) { + // Update FSM first so UI can react to new `Status` if needed. + _currentStatus = MostroFSM.nextStatus(_currentStatus, event.action); + + // Persist the message as the latest state for the order. state = event; + handleOrderUpdate(); } diff --git a/lib/features/order/providers/order_status_provider.dart b/lib/features/order/providers/order_status_provider.dart new file mode 100644 index 00000000..33cab9aa --- /dev/null +++ b/lib/features/order/providers/order_status_provider.dart @@ -0,0 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/mostro_fsm.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; + +/// Exposes a live [Status] stream for a given order based on the full history +/// of stored `MostroMessage`s. Any new message automatically recomputes the +/// status using the canonical [MostroFSM]. +final orderStatusProvider = StreamProvider.family((ref, orderId) { + final storage = ref.watch(mostroStorageProvider); + + Status computeStatus(Iterable messages) { + var status = Status.pending; // default starting point + for (final m in messages) { + status = MostroFSM.nextStatus(status, m.action); + } + return status; + } + + return storage + .watchAllMessages(orderId) // emits list whenever new message saved + .map((messages) => computeStatus(messages)) + .distinct(); +}); diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index a34e18a5..9ca8d227 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -11,6 +11,7 @@ import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; +import 'package:mostro_mobile/features/order/providers/order_status_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/features/trades/widgets/mostro_message_detail_widget.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; @@ -204,9 +205,13 @@ class TradeDetailScreen extends ConsumerWidget { final completeProvider = StateProvider((ref) => false); final errorProvider = StateProvider((ref) => null); - // The finite-state-machine approach: decide based on the order.status. - // Then refine based on the user's role and the last action. - switch (order.status) { + // Decide using canonical FSM status from provider (falls back to + // on-chain tag if not yet available). + final status = ref + .watch(orderStatusProvider(orderId)) + .maybeWhen(data: (s) => s, orElse: () => order.status); + + switch (status) { case Status.pending: // According to Mostro FSM: Pending state final widgets = []; @@ -282,23 +287,9 @@ class TradeDetailScreen extends ConsumerWidget { return widgets; case Status.settledHoldInvoice: - // According to Mostro FSM: settled-hold-invoice state - // Both buyer and seller can only wait - final widgets = []; - - // Only show rate button if that action is available - if (currentAction == actions.Action.rate) { - widgets.add(_buildNostrButton( - 'RATE', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, - backgroundColor: AppTheme.mostroGreen, - onPressed: () => context.push('/rate_user/${orderId}'), - )); - } - - return widgets; + // According to Mostro FSM: settled-hold-invoice → both parties wait. + // No user actions are permitted in this intermediate state. + return []; case Status.active: // According to Mostro FSM: active state diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index e25db673..1270499b 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -10,6 +10,7 @@ import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +import 'package:mostro_mobile/features/order/providers/order_status_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; @@ -23,10 +24,12 @@ class MostroMessageDetail extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Retrieve the MostroMessage using the order's orderId - final mostroMessage = ref.watch(orderNotifierProvider(order.orderId!)); final session = ref.watch(sessionProvider(order.orderId!)); final action = ref.watch(orderActionNotifierProvider(order.orderId!)); + // Obtain live status from canonical provider to reflect FSM. + final status = ref + .watch(orderStatusProvider(order.orderId!)) + .maybeWhen(data: (s) => s, orElse: () => order.status); // Map the action enum to the corresponding i10n string. String actionText; switch (action) { @@ -214,8 +217,7 @@ class MostroMessageDetail extends ConsumerWidget { actionText = S.of(context)!.isNotYourOrder; break; case CantDoReason.notAllowedByStatus: - actionText = - S.of(context)!.notAllowedByStatus(order.orderId!, order.status); + actionText = S.of(context)!.notAllowedByStatus(order.orderId!, status); break; case CantDoReason.outOfRangeFiatAmount: actionText = @@ -272,7 +274,7 @@ class MostroMessageDetail extends ConsumerWidget { style: AppTheme.theme.textTheme.bodyLarge, ), const SizedBox(height: 16), - Text('${order.status} - $action'), + Text('$status - $action'), ], ), ), From 01fba2630df922296041c2ff93ad9ccfb640ea73 Mon Sep 17 00:00:00 2001 From: Biz Date: Sun, 27 Apr 2025 11:17:10 +1000 Subject: [PATCH 121/149] Refactor message stream to use watchAllMessages and emit latest message --- lib/shared/providers/mostro_storage_provider.dart | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart index 15951a38..c1498711 100644 --- a/lib/shared/providers/mostro_storage_provider.dart +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -8,12 +8,13 @@ final mostroStorageProvider = Provider((ref) { return MostroStorage(db: mostroDatabase); }); -final mostroMessageStreamProvider = StreamProvider.family( - (ref, orderId) { - final storage = ref.read(mostroStorageProvider); - return storage.watchLatestMessage(orderId); - }, -); +final mostroMessageStreamProvider = StreamProvider.family((ref, orderId) { + final storage = ref.read(mostroStorageProvider); + // Emit the newest message whenever the history stream updates. + return storage + .watchAllMessages(orderId) + .map((list) => list.isNotEmpty ? list.last : null); +}); final mostroMessageHistoryProvider = StreamProvider.family, String>( (ref, orderId) { From 6b6024caa828407cd28464da35c6ad6edfe847be Mon Sep 17 00:00:00 2001 From: Biz Date: Sun, 27 Apr 2025 20:36:29 +1000 Subject: [PATCH 122/149] More refactoring of MostroStorage --- lib/data/models/session.dart | 7 +- lib/data/repositories/base_storage.dart | 243 +++++------------- lib/data/repositories/mostro_storage.dart | 80 +++--- lib/data/repositories/order_storage.dart | 95 ------- lib/data/repositories/session_storage.dart | 23 +- .../chat/notifiers/chat_room_notifier.dart | 10 +- .../notfiers/abstract_mostro_notifier.dart | 22 +- .../providers/order_notifier_provider.dart | 9 +- lib/services/mostro_service.dart | 4 +- lib/shared/notifiers/session_notifier.dart | 32 +-- test/services/mostro_service_test.mocks.dart | 5 +- 11 files changed, 132 insertions(+), 398 deletions(-) delete mode 100644 lib/data/repositories/order_storage.dart diff --git a/lib/data/models/session.dart b/lib/data/models/session.dart index c0165155..feba2707 100644 --- a/lib/data/models/session.dart +++ b/lib/data/models/session.dart @@ -64,10 +64,15 @@ class Session { Peer? get peer => _peer; set peer(Peer? newPeer) { + if (newPeer == null) { + _peer = null; + _sharedKey = null; + return; + } _peer = newPeer; _sharedKey = NostrUtils.computeSharedKey( tradeKey.private, - newPeer!.publicKey, + newPeer.publicKey, ); } } diff --git a/lib/data/repositories/base_storage.dart b/lib/data/repositories/base_storage.dart index b2339b18..a771f173 100644 --- a/lib/data/repositories/base_storage.dart +++ b/lib/data/repositories/base_storage.dart @@ -1,210 +1,83 @@ import 'package:sembast/sembast.dart'; -/// A base interface for a Sembast-backed storage of items of type [T]. +/// Base repository +/// +/// Sub-class must implement: +/// • `toDbMap` → encode T → Map +/// • `fromDbMap` → decode Map → T abstract class BaseStorage { - /// Reference to the Sembast database. final Database db; - - /// The Sembast store reference (string key, Map<String, dynamic> value). final StoreRef> store; BaseStorage(this.db, this.store); - /// Convert a domain object [T] to JSON-ready Map. Map toDbMap(T item); + T fromDbMap(String key, Map json); - /// Decode a JSON Map into domain object [T]. - T fromDbMap(String key, Map jsonMap); - - /// Insert or update an item in the store. The item is identified by [id]. - Future putItem(String id, T item) async { - final jsonMap = toDbMap(item); - await db.transaction((txn) async { - await store.record(id).put(txn, jsonMap); - }); - } + Future putItem(String id, T item) => + store.record(id).put(db, toDbMap(item)); - /// Retrieve an item by [id]. Future getItem(String id) async { - final record = await store.record(id).get(db); - if (record == null) return null; - return fromDbMap(id, record); - } - - /// Check if an item exists in the data store by [id]. - Future hasItem(String id) async { - return await store.record(id).exists(db); - } - - /// Return all items in the store. - Future> getAllItems() async { - final records = await store.find(db); - final result = []; - for (final record in records) { - try { - final item = fromDbMap(record.key, record.value); - result.add(item); - } catch (e) { - // Optionally handle or log parse errors - } - } - return result; + final json = await store.record(id).get(db); + return json == null ? null : fromDbMap(id, json); } - /// Delete an item by [id]. - Future deleteItem(String id) async { - await db.transaction((txn) async { - await store.record(id).delete(txn); - }); - } - - /// Delete all items in the store. - Future deleteAllItems() async { - await db.transaction((txn) async { - await store.delete(txn); - }); - } - - /// Delete items that match a predicate [filter]. - /// Return the list of deleted IDs. - Future> deleteWhere(bool Function(T) filter, - {int? maxBatchSize}) async { - final toDelete = []; - final records = await store.find(db); - - for (final record in records) { - if (maxBatchSize != null && toDelete.length >= maxBatchSize) { - break; - } - try { - final item = fromDbMap(record.key, record.value); - if (filter(item)) { - toDelete.add(record.key); - } - } catch (_) { - // Could not parse => also consider removing or ignoring - toDelete.add(record.key); - } - } - - // Remove the matched records in a transaction - await db.transaction((txn) async { - for (final key in toDelete) { - await store.record(key).delete(txn); - } - }); - - return toDelete; - } - - Stream> watch() { - return store.query().onSnapshots(db).map((snapshot) => snapshot - .map( - (record) => fromDbMap(record.key, record.value), - ) - .toList()); - } - - /// Watch a single item by ID with immediate value emission - Stream watchById(String id) async* { - // Emit current value immediately - yield await getItem(id); - - try { - yield* store - .record(id) - .onSnapshot(db) - .map((snapshot) => snapshot?.value != null - ? fromDbMap(id, snapshot!.value) - : null); - } catch (e) { - yield* Stream.value(null); - } + Future hasItem(String id) => store.record(id).exists(db); + + Future deleteItem(String id) => store.record(id).delete(db); + + Future deleteAll() => store.delete(db); + + /// Delete by arbitrary Sembast [Filter]. + Future deleteWhere(Filter filter) => + store.delete(db, finder: Finder(filter: filter)); + + Future> find({ + Filter? filter, + List? sort, + int? limit, + int? offset, + }) async { + final records = await store.find( + db, + finder: Finder( + filter: filter, + sortOrders: sort, + limit: limit, + offset: offset, + ), + ); + return records + .map((rec) => fromDbMap(rec.key, rec.value)) + .toList(growable: false); } - /// Watch all items with immediate value emission - Stream> watchAll() async* { - // Emit current values immediately - yield await getAllItems(); - - try { - yield* store.query().onSnapshots(db).map((snapshot) => snapshot - .map( - (record) => fromDbMap(record.key, record.value), - ) - .toList()); - } catch (e) { - yield* Stream.value([]); - } - } + Future> getAll() => find(); - /// Watch items filtered by a specific field with immediate value emission - Stream> watchByField(String field, dynamic value) async* { - // Emit current values immediately - final finder = Finder( - filter: Filter.equals(field, value), + Stream> watch({ + Filter? filter, + List? sort, + }) { + final query = store.query( + finder: Finder(filter: filter, sortOrders: sort), ); - yield await getAllItems(); - - try { - yield* store - .query(finder: finder) + return query .onSnapshots(db) - .map((snapshots) => snapshots - .map((snapshot) => fromDbMap(snapshot.key, snapshot.value)) - .toList()); - } catch (e) { - yield* Stream.value([]); - } + .map((snaps) => snaps + .map((s) => fromDbMap(s.key, s.value)) + .toList(growable: false)); } - /// Watch items filtered by a specific field with sorting - Stream> watchByFieldSorted(String field, dynamic value, String sortField, bool descending) async* { - // Emit current values immediately - final finder = Finder( - filter: Filter.equals(field, value), - sortOrders: [SortOrder(sortField, descending)], - ); - yield await getAllItems(); - - try { - yield* store - .query(finder: finder) - .onSnapshots(db) - .map((snapshots) => snapshots - .map((snapshot) => fromDbMap(snapshot.key, snapshot.value)) - .toList()); - } catch (e) { - yield* Stream.value([]); - } + /// Watch a single record by its [id] – emits *null* when deleted. + Stream watchById(String id) { + return store + .record(id) + .onSnapshot(db) + .map((snap) => snap == null ? null : fromDbMap(id, snap.value)); } - /// Watch a message by request ID with immediate value emission - Stream watchMessageByRequestId(int requestId) async* { - // Emit current value immediately - final finder = Finder( - filter: Filter.equals('request_id', requestId), - limit: 1 - ); - final snapshot = await store.findFirst(db, finder: finder); - if (snapshot != null) { - yield fromDbMap(snapshot.key, snapshot.value); - } else { - yield null; - } - - try { - yield* store - .query(finder: finder) - .onSnapshots(db) - .map((snapshots) => snapshots.isNotEmpty - ? fromDbMap(snapshots.first.key, snapshots.first.value) - : null); - } catch (e) { - yield* Stream.value(null); - } - } + // ────────────────────────── Convenience helpers ──────────────── + /// Equality filter on a given [field] (`x == value`) + Filter eq(String field, Object? value) => Filter.equals(field, value); - /// If needed, close or clean up resources here. - void dispose() {} } diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 5fac5507..c24f6ec4 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -13,16 +13,15 @@ class MostroStorage extends BaseStorage { // Generate a unique key for each message String generateMessageKey(MostroMessage message) { // Use orderId + action + requestId/tradeIndex or current timestamp for uniqueness - final uniqueSuffix = message.requestId != null - ? message.requestId.toString() - : message.tradeIndex != null - ? message.tradeIndex.toString() + final uniqueSuffix = message.requestId != null + ? message.requestId.toString() + : message.tradeIndex != null + ? message.tradeIndex.toString() : DateTime.now().millisecondsSinceEpoch.toString(); - + return '${message.id}_${message.action.name}_$uniqueSuffix'; } - /// Save or update any MostroMessage Future addMessage(MostroMessage message) async { final id = generateMessageKey(message); @@ -31,7 +30,7 @@ class MostroStorage extends BaseStorage { final Map dbMap = message.toJson(); dbMap['payload_type'] = message.payload?.runtimeType.toString(); dbMap['order_id'] = message.id; - + await store.record(id).put(db, dbMap); _logger.i( 'Saved message of type ${message.payload?.runtimeType} with id $id', @@ -70,29 +69,17 @@ class MostroStorage extends BaseStorage { /// Get all messages Future> getAllMessages() async { try { - return await getAllItems(); + return await getAll(); } catch (e, stack) { _logger.e('getAllMessages failed', error: e, stackTrace: stack); return []; } } - /// Delete a message by ID - Future deleteMessage(String orderId) async { - final id = '${T.runtimeType}:$orderId'; - try { - await deleteItem(id); - _logger.i('Message $id deleted from DB'); - } catch (e, stack) { - _logger.e('deleteMessage failed for $id', error: e, stackTrace: stack); - rethrow; - } - } - /// Delete all messages Future deleteAllMessages() async { try { - await deleteAllItems(); + await deleteAll(); _logger.i('All messages deleted'); } catch (e, stack) { _logger.e('deleteAllMessages failed', error: e, stackTrace: stack); @@ -101,7 +88,7 @@ class MostroStorage extends BaseStorage { } /// Delete all messages by Id regardless of type - Future deleteAllMessagesById(String orderId) async { + Future deleteAllMessagesByOrderId(String orderId) async { try { final messages = await getMessagesForId(orderId); for (var m in messages) { @@ -154,51 +141,52 @@ class MostroStorage extends BaseStorage { return '$type:$id'; } - Future hasMessage(MostroMessage msg) async { - return hasItem( - messageKey(msg), - ); + Future hasMessageByKey(String key) async { + return hasItem(key); } /// Get the latest message for an order, regardless of type Future getLatestMessageById(String orderId) async { - final finder = Finder( - filter: Filter.equals('order_id', orderId), - limit: 1 - ); - + final finder = Finder(filter: Filter.equals('id', orderId), limit: 1); + final snapshot = await store.findFirst(db, finder: finder); if (snapshot != null) { return MostroMessage.fromJson(snapshot.value); } return null; } - + + /// Stream of the latest message for an order Stream watchLatestMessage(String orderId) { return watchById(orderId); } - + /// Stream of all messages for an order Stream> watchAllMessages(String orderId) { - return watchByFieldSorted('id', orderId, 'timestamp', false); + return watch( + filter: Filter.equals('id', orderId), + ); } - - /// Stream of messages filtered by requestId - /// This method is special purpose - solely for initial exchange tracking - Stream watchMessagesByRequestId(int requestId) { - return watchMessageByRequestId(requestId); + + /// Stream of all messages for an order + Stream watchByRequestId(int requestId) { + final query = store.query( + finder: Finder(filter: Filter.equals('request_id', requestId)), + ); + return query + .onSnapshot(db) + .map((snap) => snap == null ? null : fromDbMap('', snap.value)); } - Future> getAllMessagesForId(String orderId) async { + Future> getAllMessagesForOrderId(String orderId) async { final finder = Finder( - filter: Filter.equals('order_id', orderId), - sortOrders: [SortOrder(Field.key, false)] - ); - + filter: Filter.equals('id', orderId), + sortOrders: [SortOrder('timestamp', false)]); + final snapshots = await store.find(db, finder: finder); return snapshots - .map((snapshot) => MostroMessage.fromJson(snapshot.value)) - .toList(); + .map((snapshot) => MostroMessage.fromJson(snapshot.value)) + .toList(); } } diff --git a/lib/data/repositories/order_storage.dart b/lib/data/repositories/order_storage.dart deleted file mode 100644 index 337856ac..00000000 --- a/lib/data/repositories/order_storage.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:async'; -import 'package:logger/logger.dart'; -import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/data/repositories/base_storage.dart'; -import 'package:sembast/sembast.dart'; - -class OrderStorage extends BaseStorage { - final Logger _logger = Logger(); - - OrderStorage({ - required Database db, - }) : super( - db, - stringMapStoreFactory.store('orders'), - ); - - Future init() async { - await getAllOrders(); - } - - /// Save or update an Order - Future addOrder(Order order) async { - final orderId = order.id; - if (orderId == null) { - throw ArgumentError('Cannot save an order with a null order.id'); - } - - try { - await putItem(orderId, order); - _logger.i('Order $orderId saved'); - } catch (e, stack) { - _logger.e('addOrder failed for $orderId', error: e, stackTrace: stack); - rethrow; - } - } - - Future addOrders(List orders) async { - for (final order in orders) { - addOrder(order); - } - } - - /// Retrieve an order by ID - Future getOrderById(String orderId) async { - try { - return await getItem(orderId); - } catch (e, stack) { - _logger.e('Error deserializing order $orderId', - error: e, stackTrace: stack); - return null; - } - } - - /// Return all orders - Future> getAllOrders() async { - try { - return await getAllItems(); - } catch (e, stack) { - _logger.e('getAllOrders failed', error: e, stackTrace: stack); - return []; - } - } - - /// Delete an order from DB - Future deleteOrder(String orderId) async { - try { - await deleteItem(orderId); - _logger.i('Order $orderId deleted from DB'); - } catch (e, stack) { - _logger.e('deleteOrder failed for $orderId', error: e, stackTrace: stack); - rethrow; - } - } - - /// Delete all orders - Future deleteAllOrders() async { - try { - await deleteAllItems(); - _logger.i('All orders deleted'); - } catch (e, stack) { - _logger.e('deleteAllOrders failed', error: e, stackTrace: stack); - rethrow; - } - } - - @override - Order fromDbMap(String key, Map jsonMap) { - return Order.fromJson(jsonMap); - } - - @override - Map toDbMap(Order item) { - return item.toJson(); - } -} diff --git a/lib/data/repositories/session_storage.dart b/lib/data/repositories/session_storage.dart index f3b67922..df722c29 100644 --- a/lib/data/repositories/session_storage.dart +++ b/lib/data/repositories/session_storage.dart @@ -56,34 +56,15 @@ class SessionStorage extends BaseStorage { Future getSession(String sessionId) => getItem(sessionId); /// Shortcut to get all sessions (direct pass-through). - Future> getAllSessions() => getAllItems(); + Future> getAllSessions() => getAll(); /// Shortcut to remove a specific session by its ID. Future deleteSession(String sessionId) => deleteItem(sessionId); - Future> deleteExpiredSessions( - int sessionExpirationHours, - int maxBatchSize, - ) async { - final now = DateTime.now(); - return await deleteWhere((session) { - final startTime = session.startTime; - return now.difference(startTime).inHours >= sessionExpirationHours; - }, maxBatchSize: maxBatchSize); - } - /// Watch a single session by ID with immediate value emission Stream watchSession(String sessionId) => watchById(sessionId); /// Watch all sessions with immediate value emission - Stream> watchAllSessions() => watchAll(); - - /// Watch sessions filtered by a specific field with immediate value emission - Stream> watchSessionsByField(String field, dynamic value) => - watchByField(field, value); + Stream> watchAllSessions() => watch(); - /// Watch sessions filtered by a specific field with sorting - Stream> watchSessionsByFieldSorted( - String field, dynamic value, String sortField, bool descending) => - watchByFieldSorted(field, value, sortField, descending); } diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index 07e86449..6a6cd1f8 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -22,9 +22,17 @@ class ChatRoomNotifier extends StateNotifier { void subscribe() { final session = ref.read(sessionProvider(orderId)); + if (session == null) { + _logger.e('Session is null'); + return; + } + if(session.sharedKey == null) { + _logger.e('Shared key is null'); + return; + } final filter = NostrFilter( kinds: [1059], - p: [session!.sharedKey!.public], + p: [session.sharedKey!.public], ); final request = NostrRequest( filters: [filter], diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index 2b892512..02bf9c34 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -120,18 +120,28 @@ class AbstractMostroNotifier extends StateNotifier { break; case Action.buyerTookOrder: final order = state.getPayload(); + if (order == null) { + logger.e('Buyer took order, but order is null'); + break; + } notifProvider.showInformation(state.action, values: { - 'buyer_npub': order?.buyerTradePubkey ?? 'Unknown', - 'fiat_code': order?.fiatCode, - 'fiat_amount': order?.fiatAmount, - 'payment_method': order?.paymentMethod, + 'buyer_npub': order.buyerTradePubkey ?? 'Unknown', + 'fiat_code': order.fiatCode, + 'fiat_amount': order.fiatAmount, + 'payment_method': order.paymentMethod, }); // add seller tradekey to session // open chat final sessionProvider = ref.read(sessionNotifierProvider.notifier); final session = sessionProvider.getSessionByOrderId(orderId); - session?.peer = Peer(publicKey: order!.buyerTradePubkey!); - sessionProvider.saveSession(session!); + if (session == null) { + logger.e('Session is null for order: $orderId'); + break; + } + session.peer = order.buyerTradePubkey != null + ? Peer(publicKey: order.buyerTradePubkey!) + : null; + sessionProvider.saveSession(session); final chat = ref.read(chatRoomsProvider(orderId).notifier); chat.subscribe(); break; diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index 60c18323..39779d15 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -75,14 +75,7 @@ class OrderTypeNotifier extends _$OrderTypeNotifier { final addOrderEventsProvider = StreamProvider.family( (ref, requestId) { final storage = ref.watch(mostroStorageProvider); - return storage.watchMessageByRequestId(requestId); - }, -); - -final orderMessageStreamProvider = StreamProvider.family( - (ref, orderId) { - final storage = ref.watch(mostroStorageProvider); - return storage.watchLatestMessage(orderId); + return storage.watchByRequestId(requestId); }, ); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index bd18cf2d..8ca04486 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -64,11 +64,11 @@ class MostroService { final messageStorage = ref.read(mostroStorageProvider); if (msg.id != null) { - if (await messageStorage.hasMessage(msg)) return; + if (await messageStorage.hasMessageByKey(decryptedEvent.id!)) return; ref.read(orderActionNotifierProvider(msg.id!).notifier).set(msg.action); } if (msg.action == Action.canceled) { - await messageStorage.deleteAllMessagesById(session.orderId!); + await messageStorage.deleteAllMessagesByOrderId(session.orderId!); await _sessionNotifier.deleteSession(session.orderId!); return; } diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index 541851cc..d9296b4d 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/data/repositories/session_storage.dart'; @@ -8,7 +7,6 @@ import 'package:mostro_mobile/features/key_manager/key_manager.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; class SessionNotifier extends StateNotifier> { - final Logger _logger = Logger(); final KeyManager _keyManager; final SessionStorage _storage; @@ -101,7 +99,7 @@ class SessionNotifier extends StateNotifier> { } Future reset() async { - await _storage.deleteAllItems(); + await _storage.deleteAll(); _sessions.clear(); state = []; } @@ -112,34 +110,6 @@ class SessionNotifier extends StateNotifier> { state = sessions; } - /// Removes sessions older than [sessionExpirationHours] from both DB and memory. - Future clearExpiredSessions() async { - try { - final removedIds = await _storage.deleteExpiredSessions( - sessionExpirationHours, - maxBatchSize, - ); - for (final id in removedIds) { - _sessions.remove(id); - } - state = sessions; - } catch (e) { - _logger.e('Error during session cleanup: $e'); - } - } - - /// Set up an initial cleanup run and a periodic timer. - void _initializeCleanup() { - _cleanupTimer?.cancel(); - // Immediately do a cleanup pass - clearExpiredSessions(); - // Schedule periodic cleanup - _cleanupTimer = Timer.periodic( - const Duration(minutes: cleanupIntervalMinutes), - (_) => clearExpiredSessions(), - ); - } - @override void dispose() { _cleanupTimer?.cancel(); diff --git a/test/services/mostro_service_test.mocks.dart b/test/services/mostro_service_test.mocks.dart index b9501017..c034f986 100644 --- a/test/services/mostro_service_test.mocks.dart +++ b/test/services/mostro_service_test.mocks.dart @@ -708,7 +708,8 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { ) as _i9.Future>>); @override - _i9.Future deleteMessage(String? orderId) => + _i9.Future deleteMessageByOrderId( + String? orderId) => (super.noSuchMethod( Invocation.method( #deleteMessage, @@ -729,7 +730,7 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { ) as _i9.Future); @override - _i9.Future deleteAllMessagesById(String? orderId) => + _i9.Future deleteAllMessagesByOrderId(String? orderId) => (super.noSuchMethod( Invocation.method( #deleteAllMessagesById, From 689ed27803d4bbab003f6590fed7fba47abdbb70 Mon Sep 17 00:00:00 2001 From: Biz Date: Sun, 27 Apr 2025 21:06:47 +1000 Subject: [PATCH 123/149] Modified status source in TradesDetailScreen and MostroMessageDetailWidget --- lib/data/repositories/mostro_storage.dart | 1 + .../chat/screens/chat_room_screen.dart | 2 +- .../chat/screens/chat_rooms_list.dart | 2 +- .../trades/screens/trade_detail_screen.dart | 24 ++++++++----------- .../widgets/mostro_message_detail_widget.dart | 6 +---- lib/services/mostro_service.dart | 1 + ...ider.dart => legible_handle_provider.dart} | 0 7 files changed, 15 insertions(+), 21 deletions(-) rename lib/shared/providers/{legible_hande_provider.dart => legible_handle_provider.dart} (100%) diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index c24f6ec4..a96955b4 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -166,6 +166,7 @@ class MostroStorage extends BaseStorage { Stream> watchAllMessages(String orderId) { return watch( filter: Filter.equals('id', orderId), + sort: [SortOrder('timestamp', true)], ); } diff --git a/lib/features/chat/screens/chat_room_screen.dart b/lib/features/chat/screens/chat_room_screen.dart index 97481fa7..2f96decb 100644 --- a/lib/features/chat/screens/chat_room_screen.dart +++ b/lib/features/chat/screens/chat_room_screen.dart @@ -9,7 +9,7 @@ import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/data/models/session.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; -import 'package:mostro_mobile/shared/providers/legible_hande_provider.dart'; +import 'package:mostro_mobile/shared/providers/legible_handle_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/widgets/clickable_text_widget.dart'; diff --git a/lib/features/chat/screens/chat_rooms_list.dart b/lib/features/chat/screens/chat_rooms_list.dart index fc4705ea..8a869605 100644 --- a/lib/features/chat/screens/chat_rooms_list.dart +++ b/lib/features/chat/screens/chat_rooms_list.dart @@ -6,7 +6,7 @@ import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; -import 'package:mostro_mobile/shared/providers/legible_hande_provider.dart'; +import 'package:mostro_mobile/shared/providers/legible_handle_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/widgets/bottom_nav_bar.dart'; import 'package:mostro_mobile/shared/widgets/mostro_app_bar.dart'; diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 9ca8d227..5c9bf897 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -207,9 +207,7 @@ class TradeDetailScreen extends ConsumerWidget { // Decide using canonical FSM status from provider (falls back to // on-chain tag if not yet available). - final status = ref - .watch(orderStatusProvider(orderId)) - .maybeWhen(data: (s) => s, orElse: () => order.status); + final status = order.status; switch (status) { case Status.pending: @@ -271,18 +269,16 @@ class TradeDetailScreen extends ConsumerWidget { backgroundColor: AppTheme.mostroGreen, onPressed: () => context.push('/add_invoice/${orderId}'), )); - - widgets.add(_buildNostrButton( - 'CANCEL', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, - backgroundColor: AppTheme.red1, - onPressed: () => - ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), - )); } - // FSM: Seller can do nothing in waiting-buyer-invoice state + widgets.add(_buildNostrButton( + 'CANCEL', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), + )); return widgets; diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index 1270499b..07790bda 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -10,7 +10,6 @@ import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; -import 'package:mostro_mobile/features/order/providers/order_status_provider.dart'; import 'package:mostro_mobile/generated/l10n.dart'; import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; @@ -26,10 +25,7 @@ class MostroMessageDetail extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final session = ref.watch(sessionProvider(order.orderId!)); final action = ref.watch(orderActionNotifierProvider(order.orderId!)); - // Obtain live status from canonical provider to reflect FSM. - final status = ref - .watch(orderStatusProvider(order.orderId!)) - .maybeWhen(data: (s) => s, orElse: () => order.status); + final status = order.status; // Map the action enum to the corresponding i10n string. String actionText; switch (action) { diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 8ca04486..8e6a5b79 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -60,6 +60,7 @@ class MostroService { final result = jsonDecode(decryptedEvent.content!); if (result is! List) return; + result[0]['timestamp'] = decryptedEvent.createdAt; final msg = MostroMessage.fromJson(result[0]); final messageStorage = ref.read(mostroStorageProvider); diff --git a/lib/shared/providers/legible_hande_provider.dart b/lib/shared/providers/legible_handle_provider.dart similarity index 100% rename from lib/shared/providers/legible_hande_provider.dart rename to lib/shared/providers/legible_handle_provider.dart From d08026d4ded1bec89f42386655d55d3fd323f217 Mon Sep 17 00:00:00 2001 From: Biz Date: Sun, 27 Apr 2025 22:31:48 +1000 Subject: [PATCH 124/149] Change Lifecycle to mobile only and Modify MostroStorage --- lib/data/repositories/mostro_storage.dart | 2 +- lib/services/lifecycle_manager.dart | 67 ++++++++++++----------- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index a96955b4..71113a94 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -166,7 +166,7 @@ class MostroStorage extends BaseStorage { Stream> watchAllMessages(String orderId) { return watch( filter: Filter.equals('id', orderId), - sort: [SortOrder('timestamp', true)], + sort: [SortOrder('timestamp', false)], ); } diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart index 98f435df..1deabd50 100644 --- a/lib/services/lifecycle_manager.dart +++ b/lib/services/lifecycle_manager.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:dart_nostr/nostr/model/request/filter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -20,23 +22,25 @@ class LifecycleManager extends WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) async { - switch (state) { - case AppLifecycleState.resumed: - // App is in foreground - if (_isInBackground) { - await _switchToForeground(); - } - break; - case AppLifecycleState.paused: - case AppLifecycleState.inactive: - case AppLifecycleState.detached: - // App is in background - if (!_isInBackground) { - await _switchToBackground(); - } - break; - default: - break; + if (Platform.isAndroid || Platform.isIOS) { + switch (state) { + case AppLifecycleState.resumed: + // App is in foreground + if (_isInBackground) { + await _switchToForeground(); + } + break; + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + case AppLifecycleState.detached: + // App is in background + if (!_isInBackground) { + await _switchToBackground(); + } + break; + default: + break; + } } } @@ -44,36 +48,36 @@ class LifecycleManager extends WidgetsBindingObserver { try { _isInBackground = false; _logger.i("Switching to foreground"); - + // Clear active subscriptions _activeSubscriptions.clear(); - + // Stop background service final backgroundService = ref.read(backgroundServiceProvider); await backgroundService.setForegroundStatus(true); _logger.i("Background service foreground status set to true"); - + // Add a small delay to ensure the background service has fully transitioned await Future.delayed(const Duration(milliseconds: 500)); - + // Reinitialize the mostro service _logger.i("Reinitializing MostroService"); ref.read(mostroServiceProvider).init(); - + // Refresh order repository by re-reading it _logger.i("Refreshing order repository"); - final orderRepo = ref.read(orderRepositoryProvider); - await orderRepo.reloadData(); - + final orderRepo = ref.read(orderRepositoryProvider); + await orderRepo.reloadData(); + // Reinitialize chat rooms _logger.i("Reloading chat rooms"); final chatRooms = ref.read(chatRoomsNotifierProvider.notifier); await chatRooms.loadChats(); - + // Force UI update for trades _logger.i("Invalidating providers to refresh UI"); ref.invalidate(filteredTradesProvider); - + _logger.i("Foreground transition complete"); } catch (e) { _logger.e("Error during foreground transition: $e"); @@ -84,18 +88,19 @@ class LifecycleManager extends WidgetsBindingObserver { try { _isInBackground = true; _logger.i("Switching to background"); - + // Transfer active subscriptions to background service final backgroundService = ref.read(backgroundServiceProvider); await backgroundService.setForegroundStatus(false); - + if (_activeSubscriptions.isNotEmpty) { - _logger.i("Transferring ${_activeSubscriptions.length} active subscriptions to background service"); + _logger.i( + "Transferring ${_activeSubscriptions.length} active subscriptions to background service"); backgroundService.subscribe(_activeSubscriptions); } else { _logger.w("No active subscriptions to transfer to background service"); } - + _logger.i("Background transition complete"); } catch (e) { _logger.e("Error during background transition: $e"); From 4e7b6c0c79d62605bcdae4c8dd4001ddbffc398d Mon Sep 17 00:00:00 2001 From: Biz Date: Mon, 28 Apr 2025 00:34:17 +1000 Subject: [PATCH 125/149] Separated events and orders databases into separate files to prevent race conditions between the main and background isolates --- lib/background/background.dart | 2 +- .../desktop_background_service.dart | 2 +- lib/data/models/mostro_message.dart | 20 ++++--- lib/data/repositories/mostro_storage.dart | 57 +++---------------- .../trades/screens/trade_detail_screen.dart | 20 +++---- lib/main.dart | 7 ++- lib/services/mostro_service.dart | 4 +- .../providers/mostro_database_provider.dart | 5 ++ .../providers/mostro_service_provider.dart | 2 +- .../providers/mostro_storage_provider.dart | 2 +- 10 files changed, 44 insertions(+), 77 deletions(-) diff --git a/lib/background/background.dart b/lib/background/background.dart index 1e26dd88..b9877173 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -20,7 +20,7 @@ Future serviceMain(ServiceInstance service) async { final Map> activeSubscriptions = {}; final nostrService = NostrService(); - final db = await openMostroDatabase('mostro.db'); + final db = await openMostroDatabase('events.db'); final eventStore = EventStorage(db: db); service.on('app-foreground-status').listen((data) { diff --git a/lib/background/desktop_background_service.dart b/lib/background/desktop_background_service.dart index 40c34ab2..583090be 100644 --- a/lib/background/desktop_background_service.dart +++ b/lib/background/desktop_background_service.dart @@ -28,7 +28,7 @@ class DesktopBackgroundService implements BackgroundService { BackgroundIsolateBinaryMessenger.ensureInitialized(token); final nostrService = NostrService(); - final db = await openMostroDatabase('background.db'); + final db = await openMostroDatabase('events.db'); final backgroundStorage = EventStorage(db: db); final logger = Logger(); bool isAppForeground = true; diff --git a/lib/data/models/mostro_message.dart b/lib/data/models/mostro_message.dart index 94e88202..ae600346 100644 --- a/lib/data/models/mostro_message.dart +++ b/lib/data/models/mostro_message.dart @@ -14,14 +14,16 @@ class MostroMessage { final Action action; int? tradeIndex; T? _payload; + int? timestamp; - MostroMessage({ - required this.action, - this.requestId, - this.id, - T? payload, - this.tradeIndex, - }) : _payload = payload; + MostroMessage( + {required this.action, + this.requestId, + this.id, + T? payload, + this.tradeIndex, + this.timestamp}) + : _payload = payload; Map toJson() { Map json = { @@ -38,9 +40,10 @@ class MostroMessage { } factory MostroMessage.fromJson(Map json) { + final timestamp = json['timestamp']; json = json['order'] ?? json['cant-do'] ?? json; - + return MostroMessage( action: Action.fromString(json['action']), requestId: json['request_id'], @@ -49,6 +52,7 @@ class MostroMessage { payload: json['payload'] != null ? Payload.fromJson(json['payload']) as T? : null, + timestamp: timestamp, ); } diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 71113a94..34f9d31b 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -10,26 +10,13 @@ class MostroStorage extends BaseStorage { MostroStorage({required Database db}) : super(db, stringMapStoreFactory.store('orders')); - // Generate a unique key for each message - String generateMessageKey(MostroMessage message) { - // Use orderId + action + requestId/tradeIndex or current timestamp for uniqueness - final uniqueSuffix = message.requestId != null - ? message.requestId.toString() - : message.tradeIndex != null - ? message.tradeIndex.toString() - : DateTime.now().millisecondsSinceEpoch.toString(); - - return '${message.id}_${message.action.name}_$uniqueSuffix'; - } - /// Save or update any MostroMessage - Future addMessage(MostroMessage message) async { - final id = generateMessageKey(message); + Future addMessage(String key, MostroMessage message) async { + final id = key; try { // Add metadata for easier querying final Map dbMap = message.toJson(); - dbMap['payload_type'] = message.payload?.runtimeType.toString(); - dbMap['order_id'] = message.id; + dbMap['timestamp'] = message.timestamp; await store.record(id).put(db, dbMap); _logger.i( @@ -45,13 +32,6 @@ class MostroStorage extends BaseStorage { } } - /// Save or update a list of MostroMessages - Future addMessages(List messages) async { - for (final message in messages) { - await addMessage(message); - } - } - /// Retrieve a MostroMessage by ID Future getMessageById( String orderId, @@ -89,24 +69,9 @@ class MostroStorage extends BaseStorage { /// Delete all messages by Id regardless of type Future deleteAllMessagesByOrderId(String orderId) async { - try { - final messages = await getMessagesForId(orderId); - for (var m in messages) { - final id = messageKey(m); - try { - await deleteItem(id); - _logger.i('Message $id deleted from DB'); - } catch (e, stack) { - _logger.e('deleteMessage failed for $id', - error: e, stackTrace: stack); - rethrow; - } - } - _logger.i('All messages for order: $orderId deleted'); - } catch (e, stack) { - _logger.e('deleteAllMessagesForId failed', error: e, stackTrace: stack); - rethrow; - } + await deleteWhere( + Filter.equals('id', orderId), + ); } /// Filter messages by payload type @@ -134,13 +99,6 @@ class MostroStorage extends BaseStorage { return item.toJson(); } - String messageKey(MostroMessage msg) { - final type = - msg.payload != null ? msg.payload.runtimeType.toString() : 'Order'; - final id = msg.id ?? msg.requestId.toString(); - return '$type:$id'; - } - Future hasMessageByKey(String key) async { return hasItem(key); } @@ -156,7 +114,6 @@ class MostroStorage extends BaseStorage { return null; } - /// Stream of the latest message for an order Stream watchLatestMessage(String orderId) { return watchById(orderId); @@ -166,7 +123,7 @@ class MostroStorage extends BaseStorage { Stream> watchAllMessages(String orderId) { return watch( filter: Filter.equals('id', orderId), - sort: [SortOrder('timestamp', false)], + sort: [SortOrder('timestamp', false, true)], ); } diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 5c9bf897..35534174 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -241,18 +241,16 @@ class TradeDetailScreen extends ConsumerWidget { backgroundColor: AppTheme.mostroGreen, onPressed: () => context.push('/pay_invoice/${orderId}'), )); - - widgets.add(_buildNostrButton( - 'CANCEL', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, - backgroundColor: AppTheme.red1, - onPressed: () => - ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), - )); } - // FSM: Buyer can do nothing in waiting-payment state + widgets.add(_buildNostrButton( + 'CANCEL', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.red1, + onPressed: () => + ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), + )); return widgets; case Status.waitingBuyerInvoice: diff --git a/lib/main.dart b/lib/main.dart index f2a4de55..0acec2cb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,7 +19,9 @@ Future main() async { final sharedPreferences = SharedPreferencesAsync(); final secureStorage = const FlutterSecureStorage(); - final database = await openMostroDatabase('mostro.db'); + final mostroDatabase = await openMostroDatabase('mostro.db'); + final eventsDatabase = await openMostroDatabase('events.db'); + final settings = SettingsNotifier(sharedPreferences); await settings.init(); @@ -37,7 +39,8 @@ Future main() async { biometricsHelperProvider.overrideWithValue(biometricsHelper), sharedPreferencesProvider.overrideWithValue(sharedPreferences), secureStorageProvider.overrideWithValue(secureStorage), - mostroDatabaseProvider.overrideWithValue(database), + mostroDatabaseProvider.overrideWithValue(mostroDatabase), + eventDatabaseProvider.overrideWithValue(eventsDatabase), ], child: const MostroApp(), ), diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 8e6a5b79..baf58476 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -60,7 +60,7 @@ class MostroService { final result = jsonDecode(decryptedEvent.content!); if (result is! List) return; - result[0]['timestamp'] = decryptedEvent.createdAt; + result[0]['timestamp'] = decryptedEvent.createdAt?.millisecondsSinceEpoch; final msg = MostroMessage.fromJson(result[0]); final messageStorage = ref.read(mostroStorageProvider); @@ -73,7 +73,7 @@ class MostroService { await _sessionNotifier.deleteSession(session.orderId!); return; } - await messageStorage.addMessage(msg); + await messageStorage.addMessage(decryptedEvent.id!, msg); if (session.orderId == null && msg.id != null) { session.orderId = msg.id; await _sessionNotifier.saveSession(session); diff --git a/lib/shared/providers/mostro_database_provider.dart b/lib/shared/providers/mostro_database_provider.dart index 94b79407..8d5b5019 100644 --- a/lib/shared/providers/mostro_database_provider.dart +++ b/lib/shared/providers/mostro_database_provider.dart @@ -17,3 +17,8 @@ Future openMostroDatabase(String dbName) async { final mostroDatabaseProvider = Provider((ref) { throw UnimplementedError(); }); + + +final eventDatabaseProvider = Provider((ref) { + throw UnimplementedError(); +}); diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index 8b5a89be..a8aedb69 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -8,7 +8,7 @@ part 'mostro_service_provider.g.dart'; @riverpod EventStorage eventStorage(Ref ref) { - final db = ref.watch(mostroDatabaseProvider); + final db = ref.watch(eventDatabaseProvider); return EventStorage(db: db); } diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart index c1498711..5fd8a6a7 100644 --- a/lib/shared/providers/mostro_storage_provider.dart +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -13,7 +13,7 @@ final mostroMessageStreamProvider = StreamProvider.family list.isNotEmpty ? list.last : null); + .map((list) => list.isNotEmpty ? list.first : null); }); final mostroMessageHistoryProvider = StreamProvider.family, String>( From bf7c471a239c2096556576300ea4aade365376b2 Mon Sep 17 00:00:00 2001 From: Biz Date: Mon, 28 Apr 2025 01:28:37 +1000 Subject: [PATCH 126/149] Fix order not saving pubkeys when serialized --- lib/data/models/order.dart | 8 ++++---- .../trades/screens/trade_detail_screen.dart | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/data/models/order.dart b/lib/data/models/order.dart index f2f544af..5f221fec 100644 --- a/lib/data/models/order.dart +++ b/lib/data/models/order.dart @@ -72,11 +72,11 @@ class Order implements Payload { data[type]['buyer_token'] = buyerToken; data[type]['seller_token'] = sellerToken; - if (masterBuyerPubkey != null) { - data[type]['buyer_trade_pubkey'] = masterBuyerPubkey; + if (buyerTradePubkey != null) { + data[type]['buyer_trade_pubkey'] = buyerTradePubkey; } - if (masterSellerPubkey != null) { - data[type]['seller_trade_pubkey'] = masterSellerPubkey; + if (sellerTradePubkey != null) { + data[type]['seller_trade_pubkey'] = sellerTradePubkey; } return data; } diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 35534174..86069735 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -281,9 +281,21 @@ class TradeDetailScreen extends ConsumerWidget { return widgets; case Status.settledHoldInvoice: - // According to Mostro FSM: settled-hold-invoice → both parties wait. - // No user actions are permitted in this intermediate state. - return []; + if (currentAction == actions.Action.rate) { + return [ + // Rate button if applicable (common for both roles) + _buildNostrButton( + 'RATE', + ref: ref, + completeProvider: completeProvider, + errorProvider: errorProvider, + backgroundColor: AppTheme.mostroGreen, + onPressed: () => context.push('/rate_user/${orderId}'), + ) + ]; + } else { + return []; + } case Status.active: // According to Mostro FSM: active state From 5d2be2433be26b20d5fe79562e453ce6759a5baa Mon Sep 17 00:00:00 2001 From: Biz Date: Mon, 28 Apr 2025 10:31:08 +1000 Subject: [PATCH 127/149] Refactor message storage and retrieval to use latest messages consistently --- lib/data/repositories/mostro_storage.dart | 29 +++++++++++++++++-- .../trades/screens/trade_detail_screen.dart | 11 ++++--- lib/shared/providers/app_init_provider.dart | 6 ++-- .../providers/mostro_storage_provider.dart | 5 +--- .../providers/order_repository_provider.dart | 4 +-- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 34f9d31b..2708123a 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -16,6 +16,9 @@ class MostroStorage extends BaseStorage { try { // Add metadata for easier querying final Map dbMap = message.toJson(); + if (message.timestamp == null) { + message.timestamp = DateTime.now().millisecondsSinceEpoch; + } dbMap['timestamp'] = message.timestamp; await store.record(id).put(db, dbMap); @@ -75,7 +78,7 @@ class MostroStorage extends BaseStorage { } /// Filter messages by payload type - Future>> getMessagesOfType() async { + Future> getMessagesOfType() async { final messages = await getAllMessages(); return messages .where((m) => m.payload is T) @@ -83,6 +86,19 @@ class MostroStorage extends BaseStorage { .toList(); } + /// Filter messages by payload type + Future getLatestMessageOfTypeById( + String orderId, + ) async { + final messages = await getMessagesForId(orderId); + for (final message in messages.reversed) { + if (message.payload is T) { + return message; + } + } + return null; + } + /// Filter messages by tradeKeyPublic Future> getMessagesForId(String orderId) async { final messages = await getAllMessages(); @@ -105,7 +121,11 @@ class MostroStorage extends BaseStorage { /// Get the latest message for an order, regardless of type Future getLatestMessageById(String orderId) async { - final finder = Finder(filter: Filter.equals('id', orderId), limit: 1); + final finder = Finder( + filter: Filter.equals('id', orderId), + sortOrders: _getDefaultSort(), + limit: 1, + ); final snapshot = await store.findFirst(db, finder: finder); if (snapshot != null) { @@ -119,11 +139,14 @@ class MostroStorage extends BaseStorage { return watchById(orderId); } + // Use the same sorting across all methods that return lists of messages + List _getDefaultSort() => [SortOrder('timestamp', false, true)]; + /// Stream of all messages for an order Stream> watchAllMessages(String orderId) { return watch( filter: Filter.equals('id', orderId), - sort: [SortOrder('timestamp', false, true)], + sort: _getDefaultSort(), ); } diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 86069735..98686f39 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -11,7 +11,6 @@ import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; -import 'package:mostro_mobile/features/order/providers/order_status_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; import 'package:mostro_mobile/features/trades/widgets/mostro_message_detail_widget.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; @@ -239,7 +238,7 @@ class TradeDetailScreen extends ConsumerWidget { completeProvider: completeProvider, errorProvider: errorProvider, backgroundColor: AppTheme.mostroGreen, - onPressed: () => context.push('/pay_invoice/${orderId}'), + onPressed: () => context.push('/pay_invoice/$orderId'), )); } widgets.add(_buildNostrButton( @@ -265,7 +264,7 @@ class TradeDetailScreen extends ConsumerWidget { completeProvider: completeProvider, errorProvider: errorProvider, backgroundColor: AppTheme.mostroGreen, - onPressed: () => context.push('/add_invoice/${orderId}'), + onPressed: () => context.push('/add_invoice/$orderId'), )); } widgets.add(_buildNostrButton( @@ -290,7 +289,7 @@ class TradeDetailScreen extends ConsumerWidget { completeProvider: completeProvider, errorProvider: errorProvider, backgroundColor: AppTheme.mostroGreen, - onPressed: () => context.push('/rate_user/${orderId}'), + onPressed: () => context.push('/rate_user/$orderId'), ) ]; } else { @@ -381,7 +380,7 @@ class TradeDetailScreen extends ConsumerWidget { completeProvider: completeProvider, errorProvider: errorProvider, backgroundColor: AppTheme.mostroGreen, - onPressed: () => context.push('/rate_user/${orderId}'), + onPressed: () => context.push('/rate_user/$orderId'), )); } @@ -478,7 +477,7 @@ class TradeDetailScreen extends ConsumerWidget { completeProvider: completeProvider, errorProvider: errorProvider, backgroundColor: AppTheme.mostroGreen, - onPressed: () => context.push('/rate_user/${orderId}'), + onPressed: () => context.push('/rate_user/$orderId'), )); } diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 8ccd64cb..8ac80fb0 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -37,10 +37,10 @@ final appInitializerProvider = FutureProvider((ref) async { for (final session in sessionManager.sessions) { if (session.orderId != null) { - final orderList = await mostroStorage.getMessagesForId(session.orderId!); - if (orderList.isNotEmpty) { + final order = await mostroStorage.getLatestMessageById(session.orderId!); + if (order != null) { ref.read(orderActionNotifierProvider(session.orderId!).notifier).set( - orderList.last.action, + order.action, ); } ref.read( diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart index 5fd8a6a7..e35a57e7 100644 --- a/lib/shared/providers/mostro_storage_provider.dart +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -10,10 +10,7 @@ final mostroStorageProvider = Provider((ref) { final mostroMessageStreamProvider = StreamProvider.family((ref, orderId) { final storage = ref.read(mostroStorageProvider); - // Emit the newest message whenever the history stream updates. - return storage - .watchAllMessages(orderId) - .map((list) => list.isNotEmpty ? list.first : null); + return storage.watchLatestMessage(orderId); }); final mostroMessageHistoryProvider = StreamProvider.family, String>( diff --git a/lib/shared/providers/order_repository_provider.dart b/lib/shared/providers/order_repository_provider.dart index 1f58ad78..7e600755 100644 --- a/lib/shared/providers/order_repository_provider.dart +++ b/lib/shared/providers/order_repository_provider.dart @@ -30,7 +30,7 @@ final eventProvider = Provider.family((ref, orderId) { data: (data) => data, orElse: () => [], ); - // firstWhereOrNull returns null if no match is found + // lastWhereOrNull returns null if no match is found return allEvents - .firstWhereOrNull((evt) => (evt as NostrEvent).orderId == orderId); + .lastWhereOrNull((evt) => (evt as NostrEvent).orderId == orderId); }); From 8a0c78b8c14b04b7313a6313b8ab33ed2dd69db1 Mon Sep 17 00:00:00 2001 From: Biz Date: Mon, 28 Apr 2025 11:17:10 +1000 Subject: [PATCH 128/149] Fix message watching and improve order notifier initialization --- lib/data/repositories/mostro_storage.dart | 13 ++++++++++++- .../order/notfiers/payment_request_notifier.dart | 6 ++++-- .../order/providers/order_notifier_provider.dart | 2 ++ lib/shared/providers/app_init_provider.dart | 13 ++++++++++--- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 2708123a..5e5e25d2 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -136,7 +136,18 @@ class MostroStorage extends BaseStorage { /// Stream of the latest message for an order Stream watchLatestMessage(String orderId) { - return watchById(orderId); + // We want to watch ALL messages for this orderId, not just a specific key + final query = store.query( + finder: Finder( + filter: Filter.equals('id', orderId), + sortOrders: _getDefaultSort(), + limit: 1, + ), + ); + + return query + .onSnapshots(db) + .map((snaps) => snaps.isEmpty ? null : MostroMessage.fromJson(snaps.first.value)); } // Use the same sorting across all methods that return lists of messages diff --git a/lib/features/order/notfiers/payment_request_notifier.dart b/lib/features/order/notfiers/payment_request_notifier.dart index 97daa357..31c67675 100644 --- a/lib/features/order/notfiers/payment_request_notifier.dart +++ b/lib/features/order/notfiers/payment_request_notifier.dart @@ -10,9 +10,11 @@ class PaymentRequestNotifier extends AbstractMostroNotifier { @override void handleEvent(MostroMessage event) { + // Only react to PaymentRequest payloads; delegate full handling to the + // base notifier so that the Finite-State Machine and generic side-effects + // (navigation, notifications, etc.) remain consistent. if (event.payload is PaymentRequest) { - state = event; - handleOrderUpdate(); + super.handleEvent(event); } } } diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index 39779d15..69fa4848 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -13,9 +13,11 @@ part 'order_notifier_provider.g.dart'; final orderNotifierProvider = StateNotifierProvider.family( (ref, orderId) { + // Initialize all related notifiers ref.read(cantDoNotifierProvider(orderId)); ref.read(paymentNotifierProvider(orderId)); ref.read(disputeNotifierProvider(orderId)); + return OrderNotifier( orderId, ref, diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 8ac80fb0..1eb7fbff 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -39,13 +39,20 @@ final appInitializerProvider = FutureProvider((ref) async { if (session.orderId != null) { final order = await mostroStorage.getLatestMessageById(session.orderId!); if (order != null) { + // Set the order action ref.read(orderActionNotifierProvider(session.orderId!).notifier).set( order.action, ); + + // Explicitly initialize EACH notifier in the family + // to ensure they're all properly set up for this orderId + ref.read(paymentNotifierProvider(session.orderId!).notifier).sync(); + ref.read(cantDoNotifierProvider(session.orderId!).notifier).sync(); + ref.read(disputeNotifierProvider(session.orderId!).notifier).sync(); } - ref.read( - orderNotifierProvider(session.orderId!), - ); + + // Read the order notifier provider last, which will watch all the above + ref.read(orderNotifierProvider(session.orderId!)); } if (session.peer != null) { From ea13f293854f58a00282876a1175f4d18dc97ccc Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 29 Apr 2025 00:31:48 +1000 Subject: [PATCH 129/149] Add TradeState model and providers to manage trade status and actions --- lib/features/trades/models/trade_state.dart | 15 + .../providers/trade_state_provider.dart | 30 ++ .../trades/screens/trade_detail_screen.dart | 149 ++++---- .../widgets/mostro_message_detail_widget.dart | 343 ++++++++---------- .../providers/mostro_storage_provider.dart | 13 +- 5 files changed, 283 insertions(+), 267 deletions(-) create mode 100644 lib/features/trades/models/trade_state.dart create mode 100644 lib/features/trades/providers/trade_state_provider.dart diff --git a/lib/features/trades/models/trade_state.dart b/lib/features/trades/models/trade_state.dart new file mode 100644 index 00000000..d82b626c --- /dev/null +++ b/lib/features/trades/models/trade_state.dart @@ -0,0 +1,15 @@ +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; + +class TradeState { + final Status status; + final actions.Action? lastAction; + final Order? orderPayload; + + TradeState({ + required this.status, + required this.lastAction, + required this.orderPayload, + }); +} diff --git a/lib/features/trades/providers/trade_state_provider.dart b/lib/features/trades/providers/trade_state_provider.dart new file mode 100644 index 00000000..5c0292f7 --- /dev/null +++ b/lib/features/trades/providers/trade_state_provider.dart @@ -0,0 +1,30 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/features/trades/models/trade_state.dart'; +import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; +import 'package:collection/collection.dart'; + +/// Provides a reactive TradeState for a given orderId. + +final tradeStateProvider = + Provider.family.autoDispose((ref, orderId) { + final statusAsync = ref.watch(eventProvider(orderId)); + final messagesAsync = ref.watch(mostroMessageHistoryProvider(orderId)); + final lastOrderMessageAsync = ref.watch(mostroOrderStreamProvider(orderId)); + + final status = statusAsync?.status ?? Status.pending; + final messages = messagesAsync.value ?? []; + final lastActionMessage = + messages.firstWhereOrNull((m) => m.action != actions.Action.cantDo); + final orderPayload = lastOrderMessageAsync.value?.getPayload(); + + return TradeState( + status: status, + lastAction: lastActionMessage?.action, + orderPayload: orderPayload, + ); +}); diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 98686f39..3c5ed4e7 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -1,5 +1,4 @@ import 'package:circular_countdown/circular_countdown.dart'; -import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -9,12 +8,11 @@ import 'package:mostro_mobile/core/app_theme.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/enums/status.dart'; -import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; +import 'package:mostro_mobile/features/trades/models/trade_state.dart'; +import 'package:mostro_mobile/features/trades/providers/trade_state_provider.dart'; import 'package:mostro_mobile/features/trades/widgets/mostro_message_detail_widget.dart'; -import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; -import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; @@ -28,10 +26,10 @@ class TradeDetailScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final order = ref.watch(eventProvider(orderId)); - - // Make sure we actually have an order from the provider: - if (order == null) { + final tradeState = ref.watch(tradeStateProvider(orderId)); + // If message is null or doesn't have an Order payload, show loading + final orderPayload = tradeState.orderPayload; + if (orderPayload == null) { return const Scaffold( backgroundColor: AppTheme.dark1, body: Center(child: CircularProgressIndicator()), @@ -41,62 +39,79 @@ class TradeDetailScreen extends ConsumerWidget { return Scaffold( backgroundColor: AppTheme.dark1, appBar: OrderAppBar(title: 'ORDER DETAILS'), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - const SizedBox(height: 16), - // Display basic info about the trade: - _buildSellerAmount(ref, order), - const SizedBox(height: 16), - _buildOrderId(context), - const SizedBox(height: 16), - // Detailed info: includes the last Mostro message action text - MostroMessageDetail(order: order), - const SizedBox(height: 24), - _buildCountDownTime(order.expirationDate), - const SizedBox(height: 36), - Wrap( - alignment: WrapAlignment.center, - spacing: 10, - runSpacing: 10, + body: Builder( + builder: (context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( children: [ - _buildCloseButton(context), - ..._buildActionButtons(context, ref, order), + const SizedBox(height: 16), + // Display basic info about the trade: + _buildSellerAmount(ref, tradeState), + const SizedBox(height: 16), + _buildOrderId(context), + const SizedBox(height: 16), + // Detailed info: includes the last Mostro message action text + MostroMessageDetail(orderId: orderId), + const SizedBox(height: 24), + _buildCountDownTime(orderPayload.expiresAt), + const SizedBox(height: 36), + Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: [ + _buildCloseButton(context), + ..._buildActionButtons( + context, + ref, + tradeState, + ), + ], + ), ], ), - ], - ), + ); + }, ), ); } /// Builds a card showing the user is "selling/buying X sats for Y fiat" etc. - Widget _buildSellerAmount(WidgetRef ref, NostrEvent order) { - final session = ref.watch(sessionProvider(order.orderId!)); + Widget _buildSellerAmount(WidgetRef ref, TradeState tradeState) { + final session = ref.watch(sessionProvider(orderId)); final selling = session!.role == Role.seller ? 'selling' : 'buying'; + final currencyFlag = CurrencyUtils.getFlagFromCurrency( + tradeState.orderPayload!.fiatCode, + ); final amountString = - '${order.fiatAmount} ${order.currency} ${CurrencyUtils.getFlagFromCurrency(order.currency!)}'; + '${tradeState.orderPayload!.fiatAmount} ${tradeState.orderPayload!.fiatCode} $currencyFlag'; - // If `order.amount` is "0", the trade is "at market price" - final isZeroAmount = (order.amount == '0'); - final satText = isZeroAmount ? '' : ' ${order.amount}'; + // If `orderPayload.amount` is 0, the trade is "at market price" + final isZeroAmount = (tradeState.orderPayload!.amount == 0); + final satText = isZeroAmount ? '' : ' ${tradeState.orderPayload!.amount}'; final priceText = isZeroAmount ? 'at market price' : ''; - final premium = int.tryParse(order.premium ?? '0') ?? 0; + final premium = tradeState.orderPayload!.premium; final premiumText = premium == 0 ? '' : (premium > 0) ? 'with a +$premium% premium' : 'with a $premium% discount'; - // Payment method can be multiple, we only display the first for brevity: - final method = order.paymentMethods.isNotEmpty - ? order.paymentMethods[0] - : 'No payment method'; - + // Payment method + final method = tradeState.orderPayload!.paymentMethod; + final timestamp = formatDateTime( + tradeState.orderPayload!.createdAt != null && + tradeState.orderPayload!.createdAt! > 0 + ? DateTime.fromMillisecondsSinceEpoch( + tradeState.orderPayload!.createdAt!) + : DateTime.fromMillisecondsSinceEpoch( + tradeState.orderPayload!.createdAt ?? 0, + ), + ); return CustomCard( padding: const EdgeInsets.all(16), child: Row( @@ -113,7 +128,7 @@ class TradeDetailScreen extends ConsumerWidget { ), const SizedBox(height: 16), Text( - 'Created on: ${formatDateTime(order.createdAt!)}', + 'Created on: $timestamp', style: textTheme.bodyLarge, ), const SizedBox(height: 16), @@ -165,7 +180,12 @@ class TradeDetailScreen extends ConsumerWidget { } /// Build a circular countdown to show how many hours are left until expiration. - Widget _buildCountDownTime(DateTime expiration) { + Widget _buildCountDownTime(int? expiresAtTimestamp) { + // Convert timestamp to DateTime + final expiration = expiresAtTimestamp != null && expiresAtTimestamp > 0 + ? DateTime.fromMillisecondsSinceEpoch(expiresAtTimestamp) + : DateTime.now().add(const Duration(hours: 24)); + // If expiration has passed, the difference is negative => zero. final now = DateTime.now(); final Duration difference = @@ -185,18 +205,11 @@ class TradeDetailScreen extends ConsumerWidget { ); } - /// Main action button area, switching on `order.status`. + /// Main action button area, switching on `orderPayload.status`. /// Additional checks use `message.action` to refine which button to show. /// Following the Mostro protocol state machine for order flow. List _buildActionButtons( - BuildContext context, WidgetRef ref, NostrEvent order) { - // Using the new messageStateProvider to ensure we get the latest message state - final messageState = ref.watch(mostroMessageStreamProvider(orderId)); - final message = - messageState.value ?? ref.watch(orderNotifierProvider(orderId)); - - // Default action if message is null - final currentAction = message?.action; + BuildContext context, WidgetRef ref, TradeState tradeState) { final session = ref.watch(sessionProvider(orderId)); final userRole = session?.role; @@ -206,7 +219,8 @@ class TradeDetailScreen extends ConsumerWidget { // Decide using canonical FSM status from provider (falls back to // on-chain tag if not yet available). - final status = order.status; + final status = tradeState.status; + // Create and return buttons list based on status switch (status) { case Status.pending: @@ -280,7 +294,7 @@ class TradeDetailScreen extends ConsumerWidget { return widgets; case Status.settledHoldInvoice: - if (currentAction == actions.Action.rate) { + if (tradeState.lastAction == actions.Action.rate) { return [ // Rate button if applicable (common for both roles) _buildNostrButton( @@ -303,8 +317,8 @@ class TradeDetailScreen extends ConsumerWidget { // Role-specific actions according to FSM if (userRole == Role.buyer) { // FSM: Buyer can fiat-sent - if (currentAction != actions.Action.fiatSentOk && - currentAction != actions.Action.fiatSent) { + if (tradeState.lastAction != actions.Action.fiatSentOk && + tradeState.lastAction != actions.Action.fiatSent) { widgets.add(_buildNostrButton( 'FIAT SENT', ref: ref, @@ -329,9 +343,9 @@ class TradeDetailScreen extends ConsumerWidget { )); // FSM: Buyer can dispute - if (currentAction != actions.Action.disputeInitiatedByYou && - currentAction != actions.Action.disputeInitiatedByPeer && - currentAction != actions.Action.dispute) { + if (tradeState.lastAction != actions.Action.disputeInitiatedByYou && + tradeState.lastAction != actions.Action.disputeInitiatedByPeer && + tradeState.lastAction != actions.Action.dispute) { widgets.add(_buildNostrButton( 'DISPUTE', ref: ref, @@ -356,9 +370,9 @@ class TradeDetailScreen extends ConsumerWidget { )); // FSM: Seller can dispute - if (currentAction != actions.Action.disputeInitiatedByYou && - currentAction != actions.Action.disputeInitiatedByPeer && - currentAction != actions.Action.dispute) { + if (tradeState.lastAction != actions.Action.disputeInitiatedByYou && + tradeState.lastAction != actions.Action.disputeInitiatedByPeer && + tradeState.lastAction != actions.Action.dispute) { widgets.add(_buildNostrButton( 'DISPUTE', ref: ref, @@ -373,7 +387,7 @@ class TradeDetailScreen extends ConsumerWidget { } // Rate button if applicable (common for both roles) - if (currentAction == actions.Action.rate) { + if (tradeState.lastAction == actions.Action.rate) { widgets.add(_buildNostrButton( 'RATE', ref: ref, @@ -450,7 +464,8 @@ class TradeDetailScreen extends ConsumerWidget { final widgets = []; // Add confirm cancel if cooperative cancel was initiated by peer - if (currentAction == actions.Action.cooperativeCancelInitiatedByPeer) { + if (tradeState.lastAction == + actions.Action.cooperativeCancelInitiatedByPeer) { widgets.add(_buildNostrButton( 'CONFIRM CANCEL', ref: ref, @@ -470,7 +485,7 @@ class TradeDetailScreen extends ConsumerWidget { final widgets = []; // FSM: Both roles can rate counterparty if not already rated - if (currentAction != actions.Action.rateReceived) { + if (tradeState.lastAction != actions.Action.rateReceived) { widgets.add(_buildNostrButton( 'RATE', ref: ref, diff --git a/lib/features/trades/widgets/mostro_message_detail_widget.dart b/lib/features/trades/widgets/mostro_message_detail_widget.dart index 07790bda..101e0b64 100644 --- a/lib/features/trades/widgets/mostro_message_detail_widget.dart +++ b/lib/features/trades/widgets/mostro_message_detail_widget.dart @@ -1,281 +1,226 @@ -import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/dispute.dart'; -import 'package:mostro_mobile/data/models/enums/cant_do_reason.dart'; import 'package:mostro_mobile/data/models/enums/role.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/features/trades/models/trade_state.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/features/mostro/mostro_instance.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; -import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/generated/l10n.dart'; -import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; +import 'package:mostro_mobile/features/trades/providers/trade_state_provider.dart'; +import 'package:mostro_mobile/data/models/enums/cant_do_reason.dart'; class MostroMessageDetail extends ConsumerWidget { - final NostrEvent order; - - const MostroMessageDetail({super.key, required this.order}); + final String orderId; + const MostroMessageDetail({super.key, required this.orderId}); @override Widget build(BuildContext context, WidgetRef ref) { - final session = ref.watch(sessionProvider(order.orderId!)); - final action = ref.watch(orderActionNotifierProvider(order.orderId!)); - final status = order.status; - // Map the action enum to the corresponding i10n string. - String actionText; + final tradeState = ref.watch(tradeStateProvider(orderId)); + + if (tradeState.lastAction == null || tradeState.orderPayload == null) { + return const CustomCard( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + + final actionText = _getActionText( + context, + ref, + tradeState, + ); + return CustomCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const CircleAvatar( + backgroundColor: Colors.grey, + foregroundImage: AssetImage('assets/images/launcher-icon.png'), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + actionText, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 16), + Text('${tradeState.status} - ${tradeState.lastAction}'), + ], + ), + ), + ], + ), + ); + } + + String _getActionText( + BuildContext context, + WidgetRef ref, + TradeState tradeState, + ) { + final action = tradeState.lastAction; + final orderPayload = tradeState.orderPayload; switch (action) { case actions.Action.newOrder: final expHrs = ref.read(orderRepositoryProvider).mostroInstance?.expiration ?? '24'; - actionText = S.of(context)!.newOrder(int.tryParse(expHrs) ?? 24); - break; + return S.of(context)!.newOrder(int.tryParse(expHrs) ?? 24); case actions.Action.canceled: - actionText = S.of(context)!.canceled(order.orderId!); - break; + return S.of(context)!.canceled(orderPayload?.id ?? ''); case actions.Action.payInvoice: final expSecs = ref .read(orderRepositoryProvider) .mostroInstance ?.expirationSeconds ?? 900; - actionText = S.of(context)!.payInvoice( - order.amount!, order.currency!, order.fiatAmount.minimum, expSecs); - break; + return S.of(context)!.payInvoice( + orderPayload?.amount.toString() ?? '', + orderPayload?.fiatCode ?? '', + orderPayload?.fiatAmount.toString() ?? '', + expSecs, + ); case actions.Action.addInvoice: final expSecs = ref .read(orderRepositoryProvider) .mostroInstance ?.expirationSeconds ?? 900; - actionText = S.of(context)!.addInvoice( - order.amount!, order.currency!, order.fiatAmount.minimum, expSecs); - break; + return S.of(context)!.addInvoice( + orderPayload?.amount.toString() ?? '', + orderPayload?.fiatCode ?? '', + orderPayload?.fiatAmount.toString() ?? '', + expSecs, + ); case actions.Action.waitingSellerToPay: final expSecs = ref .read(orderRepositoryProvider) .mostroInstance ?.expirationSeconds ?? 900; - actionText = S.of(context)!.waitingSellerToPay(order.orderId!, expSecs); - break; + return S + .of(context)! + .waitingSellerToPay(orderPayload?.id ?? '', expSecs); case actions.Action.waitingBuyerInvoice: final expSecs = ref .read(orderRepositoryProvider) .mostroInstance ?.expirationSeconds ?? 900; - actionText = S.of(context)!.waitingBuyerInvoice(expSecs); - break; + return S.of(context)!.waitingBuyerInvoice(expSecs); case actions.Action.buyerInvoiceAccepted: - actionText = S.of(context)!.buyerInvoiceAccepted; - break; + return S.of(context)!.buyerInvoiceAccepted; case actions.Action.holdInvoicePaymentAccepted: - actionText = S.of(context)!.holdInvoicePaymentAccepted( - order.fiatAmount, - order.currency!, - order.paymentMethods.firstOrNull ?? '', - session!.peer?.publicKey ?? '', + final session = ref.watch(sessionProvider(orderPayload?.id ?? '')); + return S.of(context)!.holdInvoicePaymentAccepted( + orderPayload?.fiatAmount.toString() ?? '', + orderPayload?.fiatCode ?? '', + orderPayload?.paymentMethod ?? '', + session?.peer?.publicKey ?? '', ); - break; case actions.Action.buyerTookOrder: - actionText = S.of(context)!.buyerTookOrder( - session!.peer?.publicKey ?? '', - order.currency!, - order.fiatAmount, - order.paymentMethods.firstOrNull ?? '', + final session = ref.watch(sessionProvider(orderPayload?.id ?? '')); + return S.of(context)!.buyerTookOrder( + session?.peer?.publicKey ?? '', + orderPayload?.fiatCode ?? '', + orderPayload?.fiatAmount.toString() ?? '', + orderPayload?.paymentMethod ?? '', ); - break; case actions.Action.fiatSentOk: - actionText = session!.role == Role.buyer + final session = ref.watch(sessionProvider(orderPayload?.id ?? '')); + return session!.role == Role.buyer ? S.of(context)!.fiatSentOkBuyer(session.peer!.publicKey) : S.of(context)!.fiatSentOkSeller(session.peer!.publicKey); - break; case actions.Action.released: - actionText = S.of(context)!.released('{seller_npub}'); - break; + return S.of(context)!.released('{seller_npub}'); case actions.Action.purchaseCompleted: - actionText = S.of(context)!.purchaseCompleted; - break; + return S.of(context)!.purchaseCompleted; case actions.Action.holdInvoicePaymentSettled: - actionText = S.of(context)!.holdInvoicePaymentSettled('{buyer_npub}'); - break; + return S.of(context)!.holdInvoicePaymentSettled('{buyer_npub}'); case actions.Action.rate: - actionText = S.of(context)!.rate; - break; + return S.of(context)!.rate; case actions.Action.rateReceived: - actionText = S.of(context)!.rateReceived; - break; + return S.of(context)!.rateReceived; case actions.Action.cooperativeCancelInitiatedByYou: - actionText = - S.of(context)!.cooperativeCancelInitiatedByYou(order.orderId!); - break; + return S + .of(context)! + .cooperativeCancelInitiatedByYou(orderPayload?.id ?? ''); case actions.Action.cooperativeCancelInitiatedByPeer: - actionText = - S.of(context)!.cooperativeCancelInitiatedByPeer(order.orderId!); - break; + return S + .of(context)! + .cooperativeCancelInitiatedByPeer(orderPayload?.id ?? ''); case actions.Action.cooperativeCancelAccepted: - actionText = S.of(context)!.cooperativeCancelAccepted(order.orderId!); - break; + return S.of(context)!.cooperativeCancelAccepted(orderPayload?.id ?? ''); case actions.Action.disputeInitiatedByYou: final payload = ref - .read(disputeNotifierProvider(order.orderId!)) + .read(disputeNotifierProvider(orderPayload?.id ?? '')) .getPayload(); - actionText = S + return S .of(context)! - .disputeInitiatedByYou(order.orderId!, payload!.disputeId); - break; + .disputeInitiatedByYou(orderPayload?.id ?? '', payload!.disputeId); case actions.Action.disputeInitiatedByPeer: final payload = ref - .read(disputeNotifierProvider(order.orderId!)) + .read(disputeNotifierProvider(orderPayload?.id ?? '')) .getPayload(); - actionText = S + return S .of(context)! - .disputeInitiatedByPeer(order.orderId!, payload!.disputeId); - break; + .disputeInitiatedByPeer(orderPayload?.id ?? '', payload!.disputeId); case actions.Action.adminTookDispute: - //actionText = S.of(context)!.adminTookDisputeAdmin(''); - actionText = S.of(context)!.adminTookDisputeUsers('{admin token}'); - break; + return S.of(context)!.adminTookDisputeUsers('{admin token}'); case actions.Action.adminCanceled: - //actionText = S.of(context)!.adminCanceledAdmin(''); - actionText = S.of(context)!.adminCanceledUsers(order.orderId!); - break; + return S.of(context)!.adminCanceledUsers(orderPayload?.id ?? ''); case actions.Action.adminSettled: - //actionText = S.of(context)!.adminSettledAdmin; - actionText = S.of(context)!.adminSettledUsers(order.orderId!); - break; + return S.of(context)!.adminSettledUsers(orderPayload?.id ?? ''); case actions.Action.paymentFailed: - actionText = S.of(context)!.paymentFailed('{attempts}', '{retries}'); - break; + return S.of(context)!.paymentFailed('{attempts}', '{retries}'); case actions.Action.invoiceUpdated: - actionText = S.of(context)!.invoiceUpdated; - break; + return S.of(context)!.invoiceUpdated; case actions.Action.holdInvoicePaymentCanceled: - actionText = S.of(context)!.holdInvoicePaymentCanceled; - break; + return S.of(context)!.holdInvoicePaymentCanceled; case actions.Action.cantDo: - final msg = ref.read(cantDoNotifierProvider(order.orderId!)); - final cantDo = msg.getPayload(); - if (cantDo == null) { - actionText = "Can't Do Message Not Found"; - break; - } - switch (cantDo.cantDoReason) { - case CantDoReason.invalidSignature: - actionText = S.of(context)!.invalidSignature; - break; - case CantDoReason.invalidTradeIndex: - actionText = S.of(context)!.invalidTradeIndex; - break; - case CantDoReason.invalidAmount: - actionText = S.of(context)!.invalidAmount; - break; - case CantDoReason.invalidInvoice: - actionText = S.of(context)!.invalidInvoice; - break; - case CantDoReason.invalidPaymentRequest: - actionText = S.of(context)!.invalidPaymentRequest; - break; - case CantDoReason.invalidPeer: - actionText = S.of(context)!.invalidPeer; - break; - case CantDoReason.invalidRating: - actionText = S.of(context)!.invalidRating; - break; - case CantDoReason.invalidTextMessage: - actionText = S.of(context)!.invalidTextMessage; - break; - case CantDoReason.invalidOrderKind: - actionText = S.of(context)!.invalidOrderKind; - break; - case CantDoReason.invalidOrderStatus: - actionText = S.of(context)!.invalidOrderStatus; - break; - case CantDoReason.invalidPubkey: - actionText = S.of(context)!.invalidPubkey; - break; - case CantDoReason.invalidParameters: - actionText = S.of(context)!.invalidParameters; - break; - case CantDoReason.orderAlreadyCanceled: - actionText = S.of(context)!.orderAlreadyCanceled; - break; - case CantDoReason.cantCreateUser: - actionText = S.of(context)!.cantCreateUser; - break; - case CantDoReason.isNotYourOrder: - actionText = S.of(context)!.isNotYourOrder; - break; - case CantDoReason.notAllowedByStatus: - actionText = S.of(context)!.notAllowedByStatus(order.orderId!, status); - break; - case CantDoReason.outOfRangeFiatAmount: - actionText = - S.of(context)!.outOfRangeFiatAmount('{fiat_min}', '{fiat_max}'); - break; - case CantDoReason.outOfRangeSatsAmount: - final mostroInstance = - ref.read(orderRepositoryProvider).mostroInstance; - actionText = S.of(context)!.outOfRangeSatsAmount( - mostroInstance!.maxOrderAmount, mostroInstance.minOrderAmount); - break; - case CantDoReason.isNotYourDispute: - actionText = S.of(context)!.isNotYourDispute; - break; - case CantDoReason.disputeCreationError: - actionText = S.of(context)!.disputeCreationError; - break; - case CantDoReason.notFound: - actionText = S.of(context)!.notFound; - break; - case CantDoReason.invalidDisputeStatus: - actionText = S.of(context)!.invalidDisputeStatus; - break; - case CantDoReason.invalidAction: - actionText = S.of(context)!.invalidAction; - break; - case CantDoReason.pendingOrderExists: - actionText = S.of(context)!.pendingOrderExists; - break; - } - break; - case actions.Action.adminAddSolver: - actionText = S.of(context)!.adminAddSolver('{admin_solver}'); - break; + return _getCantDoMessage(context, ref, tradeState); default: - actionText = ''; + return 'No message found for action ${tradeState.lastAction}'; } + } - return CustomCard( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const CircleAvatar( - backgroundColor: AppTheme.grey2, - foregroundImage: AssetImage('assets/images/launcher-icon.png'), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - actionText, - style: AppTheme.theme.textTheme.bodyLarge, - ), - const SizedBox(height: 16), - Text('$status - $action'), - ], - ), - ), - ], - ), - ); + String _getCantDoMessage(BuildContext context, WidgetRef ref, TradeState tradeState) { + final orderPayload = tradeState.orderPayload; + final status = tradeState.status; + final cantDoReason = ref + .read(cantDoNotifierProvider(orderPayload?.id ?? '')) + .getPayload(); + switch (cantDoReason) { + case CantDoReason.invalidSignature: + return S.of(context)!.invalidSignature; + case CantDoReason.notAllowedByStatus: + return S.of(context)!.notAllowedByStatus(orderPayload?.id ?? '', status); + case CantDoReason.outOfRangeFiatAmount: + return S.of(context)!.outOfRangeFiatAmount('{fiat_min}', '{fiat_max}'); + case CantDoReason.outOfRangeSatsAmount: + final mostroInstance = ref.read(orderRepositoryProvider).mostroInstance; + return S.of(context)!.outOfRangeSatsAmount( + mostroInstance!.maxOrderAmount, mostroInstance.minOrderAmount); + case CantDoReason.isNotYourDispute: + return S.of(context)!.isNotYourDispute; + case CantDoReason.disputeCreationError: + return S.of(context)!.disputeCreationError; + case CantDoReason.invalidDisputeStatus: + return S.of(context)!.invalidDisputeStatus; + case CantDoReason.invalidAction: + return S.of(context)!.invalidAction; + case CantDoReason.pendingOrderExists: + return S.of(context)!.pendingOrderExists; + default: + return '${status.toString()} - ${tradeState.lastAction}'; + } } } diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart index e35a57e7..a23514d8 100644 --- a/lib/shared/providers/mostro_storage_provider.dart +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/data/repositories/mostro_storage.dart'; import 'package:mostro_mobile/shared/providers/mostro_database_provider.dart'; @@ -18,4 +19,14 @@ final mostroMessageHistoryProvider = StreamProvider.family, final storage = ref.read(mostroStorageProvider); return storage.watchAllMessages(orderId); }, -); \ No newline at end of file +); + +final mostroOrderStreamProvider = FutureProvider.family((ref, orderId) async { + final storage = ref.read(mostroStorageProvider); + return await storage.getLatestMessageOfTypeById(orderId); +}); + +final mostroOrderProvider = FutureProvider.family((ref, orderId) async { + final storage = ref.read(mostroStorageProvider); + return await storage.getLatestMessageById(orderId); +}); From 2ff522e2492f4b0accd5c07b0a46e6df648b759b Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 29 Apr 2025 01:35:46 +1000 Subject: [PATCH 130/149] Cleanup TradeDetailScreen --- .../trades/screens/trade_detail_screen.dart | 97 ------------------- 1 file changed, 97 deletions(-) diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 3c5ed4e7..3fbec14e 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -566,92 +566,6 @@ class TradeDetailScreen extends ConsumerWidget { ); } - /// RELEASE - Widget _buildReleaseButton(WidgetRef ref) { - final orderDetailsNotifier = - ref.read(orderNotifierProvider(orderId).notifier); - - return ElevatedButton( - onPressed: () async { - await orderDetailsNotifier.releaseOrder(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('RELEASE SATS'), - ); - } - - /// FIAT_SENT - Widget _buildFiatSentButton(WidgetRef ref) { - final orderDetailsNotifier = - ref.read(orderNotifierProvider(orderId).notifier); - - return ElevatedButton( - onPressed: () async { - await orderDetailsNotifier.sendFiatSent(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('FIAT SENT'), - ); - } - - /// ADD INVOICE - Widget _buildAddInvoiceButton(BuildContext context) { - return ElevatedButton( - onPressed: () async { - context.push('/add_invoice/$orderId'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('ADD INVOICE'), - ); - } - - /// ADD INVOICE - Widget _buildPayInvoiceButton(BuildContext context) { - return ElevatedButton( - onPressed: () async { - context.push('/pay_invoice/$orderId'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.mostroGreen, - ), - child: const Text('ADD INVOICE'), - ); - } - - /// CANCEL - Widget _buildCancelButton(BuildContext context, WidgetRef ref) { - final notifier = ref.read(orderNotifierProvider(orderId).notifier); - return ElevatedButton( - onPressed: () async { - await notifier.cancelOrder(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - ), - child: const Text('CANCEL'), - ); - } - - /// DISPUTE - Widget _buildDisputeButton(WidgetRef ref) { - final notifier = ref.read(orderNotifierProvider(orderId).notifier); - return ElevatedButton( - onPressed: () async { - await notifier.disputeOrder(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.red1, - ), - child: const Text('DISPUTE'), - ); - } - /// CLOSE Widget _buildCloseButton(BuildContext context) { return OutlinedButton( @@ -661,17 +575,6 @@ class TradeDetailScreen extends ConsumerWidget { ); } - /// RATE - Widget _buildRateButton(BuildContext context) { - return OutlinedButton( - onPressed: () { - context.push('/rate_user/$orderId'); - }, - style: AppTheme.theme.outlinedButtonTheme.style, - child: const Text('RATE'), - ); - } - /// Format the date time to a user-friendly string with UTC offset String formatDateTime(DateTime dt) { final dateFormatter = DateFormat('EEE MMM dd yyyy HH:mm:ss'); From 136aa0d31d627cedfc200b59a527e6d7ab610125 Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 29 Apr 2025 10:09:37 +1000 Subject: [PATCH 131/149] Convert FutureProvider to StreamProvider for real-time order updates --- lib/data/repositories/mostro_storage.dart | 20 +++++++++++++++++++ .../providers/mostro_storage_provider.dart | 8 ++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/data/repositories/mostro_storage.dart b/lib/data/repositories/mostro_storage.dart index 5e5e25d2..6de134c7 100644 --- a/lib/data/repositories/mostro_storage.dart +++ b/lib/data/repositories/mostro_storage.dart @@ -150,6 +150,26 @@ class MostroStorage extends BaseStorage { .map((snaps) => snaps.isEmpty ? null : MostroMessage.fromJson(snaps.first.value)); } + /// Stream of the latest message for an order whose payload is of type T + Stream watchLatestMessageOfType(String orderId) { + // Watch all messages for the orderId, sorted by timestamp descending + final query = store.query( + finder: Finder( + filter: Filter.equals('id', orderId), + sortOrders: _getDefaultSort(), + ), + ); + return query.onSnapshots(db).map((snaps) { + for (final snap in snaps) { + final msg = MostroMessage.fromJson(snap.value); + if (msg.payload is T) { + return msg; + } + } + return null; + }); + } + // Use the same sorting across all methods that return lists of messages List _getDefaultSort() => [SortOrder('timestamp', false, true)]; diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart index a23514d8..7111c208 100644 --- a/lib/shared/providers/mostro_storage_provider.dart +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -21,12 +21,12 @@ final mostroMessageHistoryProvider = StreamProvider.family, }, ); -final mostroOrderStreamProvider = FutureProvider.family((ref, orderId) async { +final mostroOrderStreamProvider = StreamProvider.family((ref, orderId) { final storage = ref.read(mostroStorageProvider); - return await storage.getLatestMessageOfTypeById(orderId); + return storage.watchLatestMessageOfType(orderId); }); -final mostroOrderProvider = FutureProvider.family((ref, orderId) async { +final mostroOrderProvider = StreamProvider.family((ref, orderId) { final storage = ref.read(mostroStorageProvider); - return await storage.getLatestMessageById(orderId); + return storage.watchLatestMessage(orderId); }); From 965304a88a097ea2d6c3e99e04dd8b9ca7f21fce Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 29 Apr 2025 10:27:02 +1000 Subject: [PATCH 132/149] Make mostroService and eventStorage providers keepAlive and remove cancel action handling --- lib/services/mostro_service.dart | 5 - .../providers/mostro_service_provider.dart | 4 +- .../providers/mostro_service_provider.g.dart | 12 +- test/services/mostro_service_test.mocks.dart | 248 +++++++++--------- 4 files changed, 138 insertions(+), 131 deletions(-) diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index baf58476..90991a79 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -68,11 +68,6 @@ class MostroService { if (await messageStorage.hasMessageByKey(decryptedEvent.id!)) return; ref.read(orderActionNotifierProvider(msg.id!).notifier).set(msg.action); } - if (msg.action == Action.canceled) { - await messageStorage.deleteAllMessagesByOrderId(session.orderId!); - await _sessionNotifier.deleteSession(session.orderId!); - return; - } await messageStorage.addMessage(decryptedEvent.id!, msg); if (session.orderId == null && msg.id != null) { session.orderId = msg.id; diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index a8aedb69..1129784a 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -6,13 +6,13 @@ import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'mostro_service_provider.g.dart'; -@riverpod +@Riverpod(keepAlive: true) EventStorage eventStorage(Ref ref) { final db = ref.watch(eventDatabaseProvider); return EventStorage(db: db); } -@riverpod +@Riverpod(keepAlive: true) MostroService mostroService(Ref ref) { final sessionNotifier = ref.read(sessionNotifierProvider.notifier); final mostroService = MostroService( diff --git a/lib/shared/providers/mostro_service_provider.g.dart b/lib/shared/providers/mostro_service_provider.g.dart index a7683766..cd970ea4 100644 --- a/lib/shared/providers/mostro_service_provider.g.dart +++ b/lib/shared/providers/mostro_service_provider.g.dart @@ -6,11 +6,11 @@ part of 'mostro_service_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$eventStorageHash() => r'671aa58f4248e9d508ea22bcc85da2d19d36b0b6'; +String _$eventStorageHash() => r'ba0996d381cefc0cc74a999ed3b83baf77446117'; /// See also [eventStorage]. @ProviderFor(eventStorage) -final eventStorageProvider = AutoDisposeProvider.internal( +final eventStorageProvider = Provider.internal( eventStorage, name: r'eventStorageProvider', debugGetCreateSourceHash: @@ -21,12 +21,12 @@ final eventStorageProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef EventStorageRef = AutoDisposeProviderRef; -String _$mostroServiceHash() => r'2a8b4a5218fdb1eff4bdd385d3107475d0295a8b'; +typedef EventStorageRef = ProviderRef; +String _$mostroServiceHash() => r'41bba48eb8dcfb160c783c1f1bde78928c3df1cb'; /// See also [mostroService]. @ProviderFor(mostroService) -final mostroServiceProvider = AutoDisposeProvider.internal( +final mostroServiceProvider = Provider.internal( mostroService, name: r'mostroServiceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') @@ -38,6 +38,6 @@ final mostroServiceProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef MostroServiceRef = AutoDisposeProviderRef; +typedef MostroServiceRef = ProviderRef; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/test/services/mostro_service_test.mocks.dart b/test/services/mostro_service_test.mocks.dart index c034f986..dc80e4cf 100644 --- a/test/services/mostro_service_test.mocks.dart +++ b/test/services/mostro_service_test.mocks.dart @@ -107,6 +107,16 @@ class _FakeMostroMessage_6 extends _i1.SmartFake ); } +class _FakeFilter_7 extends _i1.SmartFake implements _i5.Filter { + _FakeFilter_7( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [NostrService]. /// /// See the documentation for Mockito's code generation for more information. @@ -570,16 +580,6 @@ class MockSessionNotifier extends _i1.Mock implements _i12.SessionNotifier { returnValueForMissingStub: _i9.Future.value(), ) as _i9.Future); - @override - _i9.Future clearExpiredSessions() => (super.noSuchMethod( - Invocation.method( - #clearExpiredSessions, - [], - ), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) as _i9.Future); - @override void dispose() => super.noSuchMethod( Invocation.method( @@ -647,39 +647,17 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { ) as _i5.StoreRef>); @override - String generateMessageKey(_i7.MostroMessage<_i6.Payload>? message) => - (super.noSuchMethod( - Invocation.method( - #generateMessageKey, - [message], - ), - returnValue: _i11.dummyValue( - this, - Invocation.method( - #generateMessageKey, - [message], - ), - ), - ) as String); - - @override - _i9.Future addMessage(_i7.MostroMessage<_i6.Payload>? message) => + _i9.Future addMessage( + String? key, + _i7.MostroMessage<_i6.Payload>? message, + ) => (super.noSuchMethod( Invocation.method( #addMessage, - [message], - ), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) as _i9.Future); - - @override - _i9.Future addMessages( - List<_i7.MostroMessage<_i6.Payload>>? messages) => - (super.noSuchMethod( - Invocation.method( - #addMessages, - [messages], + [ + key, + message, + ], ), returnValue: _i9.Future.value(), returnValueForMissingStub: _i9.Future.value(), @@ -707,18 +685,6 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { <_i7.MostroMessage<_i6.Payload>>[]), ) as _i9.Future>>); - @override - _i9.Future deleteMessageByOrderId( - String? orderId) => - (super.noSuchMethod( - Invocation.method( - #deleteMessage, - [orderId], - ), - returnValue: _i9.Future.value(), - returnValueForMissingStub: _i9.Future.value(), - ) as _i9.Future); - @override _i9.Future deleteAllMessages() => (super.noSuchMethod( Invocation.method( @@ -733,7 +699,7 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { _i9.Future deleteAllMessagesByOrderId(String? orderId) => (super.noSuchMethod( Invocation.method( - #deleteAllMessagesById, + #deleteAllMessagesByOrderId, [orderId], ), returnValue: _i9.Future.value(), @@ -741,15 +707,26 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { ) as _i9.Future); @override - _i9.Future>> + _i9.Future>> getMessagesOfType() => (super.noSuchMethod( Invocation.method( #getMessagesOfType, [], ), - returnValue: _i9.Future>>.value( - <_i7.MostroMessage>[]), - ) as _i9.Future>>); + returnValue: _i9.Future>>.value( + <_i7.MostroMessage<_i6.Payload>>[]), + ) as _i9.Future>>); + + @override + _i9.Future<_i7.MostroMessage<_i6.Payload>?> + getLatestMessageOfTypeById(String? orderId) => + (super.noSuchMethod( + Invocation.method( + #getLatestMessageOfTypeById, + [orderId], + ), + returnValue: _i9.Future<_i7.MostroMessage<_i6.Payload>?>.value(), + ) as _i9.Future<_i7.MostroMessage<_i6.Payload>?>); @override _i9.Future>> getMessagesForId( @@ -799,26 +776,10 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { ) as Map); @override - String messageKey(_i7.MostroMessage<_i6.Payload>? msg) => (super.noSuchMethod( - Invocation.method( - #messageKey, - [msg], - ), - returnValue: _i11.dummyValue( - this, - Invocation.method( - #messageKey, - [msg], - ), - ), - ) as String); - - @override - _i9.Future hasMessage(_i7.MostroMessage<_i6.Payload>? msg) => - (super.noSuchMethod( + _i9.Future hasMessageByKey(String? key) => (super.noSuchMethod( Invocation.method( - #hasMessage, - [msg], + #hasMessageByKey, + [key], ), returnValue: _i9.Future.value(false), ) as _i9.Future); @@ -845,6 +806,17 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { returnValue: _i9.Stream<_i7.MostroMessage<_i6.Payload>?>.empty(), ) as _i9.Stream<_i7.MostroMessage<_i6.Payload>?>); + @override + _i9.Stream<_i7.MostroMessage<_i6.Payload>?> watchLatestMessageOfType( + String? orderId) => + (super.noSuchMethod( + Invocation.method( + #watchLatestMessageOfType, + [orderId], + ), + returnValue: _i9.Stream<_i7.MostroMessage<_i6.Payload>?>.empty(), + ) as _i9.Stream<_i7.MostroMessage<_i6.Payload>?>); + @override _i9.Stream>> watchAllMessages( String? orderId) => @@ -857,16 +829,28 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { ) as _i9.Stream>>); @override - _i9.Stream<_i7.MostroMessage<_i6.Payload>?> watchMessagesByRequestId( + _i9.Stream<_i7.MostroMessage<_i6.Payload>?> watchByRequestId( int? requestId) => (super.noSuchMethod( Invocation.method( - #watchMessagesByRequestId, + #watchByRequestId, [requestId], ), returnValue: _i9.Stream<_i7.MostroMessage<_i6.Payload>?>.empty(), ) as _i9.Stream<_i7.MostroMessage<_i6.Payload>?>); + @override + _i9.Future>> getAllMessagesForOrderId( + String? orderId) => + (super.noSuchMethod( + Invocation.method( + #getAllMessagesForOrderId, + [orderId], + ), + returnValue: _i9.Future>>.value( + <_i7.MostroMessage<_i6.Payload>>[]), + ) as _i9.Future>>); + @override _i9.Future putItem( String? id, @@ -903,17 +887,6 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { returnValue: _i9.Future.value(false), ) as _i9.Future); - @override - _i9.Future>> getAllItems() => - (super.noSuchMethod( - Invocation.method( - #getAllItems, - [], - ), - returnValue: _i9.Future>>.value( - <_i7.MostroMessage<_i6.Payload>>[]), - ) as _i9.Future>>); - @override _i9.Future deleteItem(String? id) => (super.noSuchMethod( Invocation.method( @@ -925,9 +898,9 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { ) as _i9.Future); @override - _i9.Future deleteAllItems() => (super.noSuchMethod( + _i9.Future deleteAll() => (super.noSuchMethod( Invocation.method( - #deleteAllItems, + #deleteAll, [], ), returnValue: _i9.Future.value(), @@ -935,57 +908,96 @@ class MockMostroStorage extends _i1.Mock implements _i16.MostroStorage { ) as _i9.Future); @override - _i9.Future> deleteWhere( - bool Function(_i7.MostroMessage<_i6.Payload>)? filter, { - int? maxBatchSize, - }) => - (super.noSuchMethod( + _i9.Future deleteWhere(_i5.Filter? filter) => (super.noSuchMethod( Invocation.method( #deleteWhere, [filter], - {#maxBatchSize: maxBatchSize}, ), - returnValue: _i9.Future>.value([]), - ) as _i9.Future>); + returnValue: _i9.Future.value(0), + ) as _i9.Future); @override - _i9.Stream>> watch() => + _i9.Future>> find({ + _i5.Filter? filter, + List<_i5.SortOrder>? sort, + int? limit, + int? offset, + }) => (super.noSuchMethod( Invocation.method( - #watch, + #find, [], + { + #filter: filter, + #sort: sort, + #limit: limit, + #offset: offset, + }, ), - returnValue: _i9.Stream>>.empty(), - ) as _i9.Stream>>); + returnValue: _i9.Future>>.value( + <_i7.MostroMessage<_i6.Payload>>[]), + ) as _i9.Future>>); @override - _i9.Stream<_i7.MostroMessage<_i6.Payload>?> watchMessageForOrderId( - String? orderId) => + _i9.Future>> getAll() => (super.noSuchMethod( Invocation.method( - #watchMessageForOrderId, - [orderId], + #getAll, + [], ), - returnValue: _i9.Stream<_i7.MostroMessage<_i6.Payload>?>.empty(), - ) as _i9.Stream<_i7.MostroMessage<_i6.Payload>?>); + returnValue: _i9.Future>>.value( + <_i7.MostroMessage<_i6.Payload>>[]), + ) as _i9.Future>>); @override - _i9.Stream>> watchAllMessagesForOrderId( - String? orderId) => + _i9.Stream>> watch({ + _i5.Filter? filter, + List<_i5.SortOrder>? sort, + }) => (super.noSuchMethod( Invocation.method( - #watchAllMessagesForOrderId, - [orderId], + #watch, + [], + { + #filter: filter, + #sort: sort, + }, ), returnValue: _i9.Stream>>.empty(), ) as _i9.Stream>>); @override - void dispose() => super.noSuchMethod( + _i9.Stream<_i7.MostroMessage<_i6.Payload>?> watchById(String? id) => + (super.noSuchMethod( Invocation.method( - #dispose, - [], + #watchById, + [id], ), - returnValueForMissingStub: null, - ); + returnValue: _i9.Stream<_i7.MostroMessage<_i6.Payload>?>.empty(), + ) as _i9.Stream<_i7.MostroMessage<_i6.Payload>?>); + + @override + _i5.Filter eq( + String? field, + Object? value, + ) => + (super.noSuchMethod( + Invocation.method( + #eq, + [ + field, + value, + ], + ), + returnValue: _FakeFilter_7( + this, + Invocation.method( + #eq, + [ + field, + value, + ], + ), + ), + ) as _i5.Filter); } From 24d0e721eb4e95312ff7e1c6b6d9cd3ddbc5a9ce Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 29 Apr 2025 11:06:04 +1000 Subject: [PATCH 133/149] Add dispose logic for order notifiers and handle order cancellation cleanup --- lib/features/order/notfiers/order_notifier.dart | 9 +++++++++ lib/services/mostro_service.dart | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index 5957a4e2..b292876b 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -3,6 +3,7 @@ import 'package:mostro_mobile/data/models/enums/action.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/features/order/notfiers/abstract_mostro_notifier.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/services/mostro_service.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; @@ -78,4 +79,12 @@ class OrderNotifier extends AbstractMostroNotifier { rating, ); } + + @override + void dispose() { + ref.read(cantDoNotifierProvider(orderId).notifier).dispose(); + ref.read(paymentNotifierProvider(orderId).notifier).dispose(); + ref.read(disputeNotifierProvider(orderId).notifier).dispose(); + super.dispose(); + } } diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 90991a79..c809d549 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:dart_nostr/nostr/model/export.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models.dart'; +import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/services/lifecycle_manager.dart'; @@ -68,6 +69,12 @@ class MostroService { if (await messageStorage.hasMessageByKey(decryptedEvent.id!)) return; ref.read(orderActionNotifierProvider(msg.id!).notifier).set(msg.action); } + if (msg.action == Action.canceled) { + ref.read(orderNotifierProvider(session.orderId!).notifier).dispose(); + await messageStorage.deleteAllMessagesByOrderId(session.orderId!); + await _sessionNotifier.deleteSession(session.orderId!); + return; + } await messageStorage.addMessage(decryptedEvent.id!, msg); if (session.orderId == null && msg.id != null) { session.orderId = msg.id; From 0fe7d695de4b8b3c99866bc161f87504d41e5026 Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 29 Apr 2025 12:21:10 +1000 Subject: [PATCH 134/149] Refactor NostrResponsiveButton to use direct action/orderId instead of state providers --- .../order/screens/add_order_screen.dart | 26 +-- .../trades/screens/trade_detail_screen.dart | 95 +++------- .../widgets/nostr_responsive_button.dart | 174 +++--------------- 3 files changed, 49 insertions(+), 246 deletions(-) diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 4df15825..d92e21ef 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -464,30 +464,8 @@ class _AddOrderScreenState extends ConsumerState { label: 'SUBMIT', buttonStyle: ButtonStyleType.raised, width: 120, - // Use an explicit provider that responds to the latest order message state - completionProvider: StateProvider((ref) { - final requestId = _currentRequestId; // Track the current request ID - if (requestId == null) return false; - - // Check if we have a completed order message - final messageState = ref.watch(addOrderEventsProvider(requestId)); - return messageState.whenOrNull( - data: (msg) => msg?.action == nostr_action.Action.newOrder && msg?.id != null, - ) ?? false; - }), - // Error provider based on order action status - errorProvider: StateProvider((ref) { - final requestId = _currentRequestId; - if (requestId == null) return null; - - // Check for CantDo response - final messageState = ref.watch(addOrderEventsProvider(requestId)); - return messageState.whenOrNull( - data: (msg) => msg?.payload is CantDo - ? (msg?.getPayload()?.cantDoReason.toString() ?? 'Failed to create order') - : null, - ); - }), + orderId: _currentRequestId?.toString() ?? '', + action: nostr_action.Action.newOrder, onPressed: () { if (_formKey.currentState?.validate() ?? false) { _submitOrder(context, ref, orderType); diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 3fbec14e..8515b9e9 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -213,16 +213,7 @@ class TradeDetailScreen extends ConsumerWidget { final session = ref.watch(sessionProvider(orderId)); final userRole = session?.role; - // State providers for NostrResponsiveButton - final completeProvider = StateProvider((ref) => false); - final errorProvider = StateProvider((ref) => null); - - // Decide using canonical FSM status from provider (falls back to - // on-chain tag if not yet available). - final status = tradeState.status; - // Create and return buttons list based on status - - switch (status) { + switch (tradeState.status) { case Status.pending: // According to Mostro FSM: Pending state final widgets = []; @@ -230,9 +221,7 @@ class TradeDetailScreen extends ConsumerWidget { // FSM: In pending state, seller can cancel widgets.add(_buildNostrButton( 'CANCEL', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.cancel, backgroundColor: AppTheme.red1, onPressed: () => ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), @@ -248,18 +237,14 @@ class TradeDetailScreen extends ConsumerWidget { if (userRole == Role.seller) { widgets.add(_buildNostrButton( 'PAY INVOICE', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.waitingBuyerInvoice, backgroundColor: AppTheme.mostroGreen, onPressed: () => context.push('/pay_invoice/$orderId'), )); } widgets.add(_buildNostrButton( 'CANCEL', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.canceled, backgroundColor: AppTheme.red1, onPressed: () => ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), @@ -274,18 +259,14 @@ class TradeDetailScreen extends ConsumerWidget { if (userRole == Role.buyer) { widgets.add(_buildNostrButton( 'ADD INVOICE', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.payInvoice, backgroundColor: AppTheme.mostroGreen, onPressed: () => context.push('/add_invoice/$orderId'), )); } widgets.add(_buildNostrButton( 'CANCEL', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.canceled, backgroundColor: AppTheme.red1, onPressed: () => ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), @@ -299,9 +280,7 @@ class TradeDetailScreen extends ConsumerWidget { // Rate button if applicable (common for both roles) _buildNostrButton( 'RATE', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.rateReceived, backgroundColor: AppTheme.mostroGreen, onPressed: () => context.push('/rate_user/$orderId'), ) @@ -321,9 +300,7 @@ class TradeDetailScreen extends ConsumerWidget { tradeState.lastAction != actions.Action.fiatSent) { widgets.add(_buildNostrButton( 'FIAT SENT', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.fiatSentOk, backgroundColor: AppTheme.mostroGreen, onPressed: () => ref .read(orderNotifierProvider(orderId).notifier) @@ -334,9 +311,7 @@ class TradeDetailScreen extends ConsumerWidget { // FSM: Buyer can cancel widgets.add(_buildNostrButton( 'CANCEL', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.canceled, backgroundColor: AppTheme.red1, onPressed: () => ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), @@ -348,9 +323,7 @@ class TradeDetailScreen extends ConsumerWidget { tradeState.lastAction != actions.Action.dispute) { widgets.add(_buildNostrButton( 'DISPUTE', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.disputeInitiatedByYou, backgroundColor: AppTheme.red1, onPressed: () => ref .read(orderNotifierProvider(orderId).notifier) @@ -361,9 +334,7 @@ class TradeDetailScreen extends ConsumerWidget { // FSM: Seller can cancel widgets.add(_buildNostrButton( 'CANCEL', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.canceled, backgroundColor: AppTheme.red1, onPressed: () => ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), @@ -375,9 +346,7 @@ class TradeDetailScreen extends ConsumerWidget { tradeState.lastAction != actions.Action.dispute) { widgets.add(_buildNostrButton( 'DISPUTE', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.disputeInitiatedByYou, backgroundColor: AppTheme.red1, onPressed: () => ref .read(orderNotifierProvider(orderId).notifier) @@ -390,9 +359,7 @@ class TradeDetailScreen extends ConsumerWidget { if (tradeState.lastAction == actions.Action.rate) { widgets.add(_buildNostrButton( 'RATE', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.rateReceived, backgroundColor: AppTheme.mostroGreen, onPressed: () => context.push('/rate_user/$orderId'), )); @@ -412,9 +379,7 @@ class TradeDetailScreen extends ConsumerWidget { // FSM: Seller can release widgets.add(_buildNostrButton( 'RELEASE SATS', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.released, backgroundColor: AppTheme.mostroGreen, onPressed: () => ref .read(orderNotifierProvider(orderId).notifier) @@ -424,9 +389,7 @@ class TradeDetailScreen extends ConsumerWidget { // FSM: Seller can cancel widgets.add(_buildNostrButton( 'CANCEL', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.canceled, backgroundColor: AppTheme.red1, onPressed: () => ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), @@ -435,9 +398,7 @@ class TradeDetailScreen extends ConsumerWidget { // FSM: Seller can dispute widgets.add(_buildNostrButton( 'DISPUTE', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.disputeInitiatedByYou, backgroundColor: AppTheme.red1, onPressed: () => ref .read(orderNotifierProvider(orderId).notifier) @@ -447,9 +408,7 @@ class TradeDetailScreen extends ConsumerWidget { // FSM: Buyer can only dispute in fiat-sent state widgets.add(_buildNostrButton( 'DISPUTE', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.disputeInitiatedByYou, backgroundColor: AppTheme.red1, onPressed: () => ref .read(orderNotifierProvider(orderId).notifier) @@ -468,9 +427,7 @@ class TradeDetailScreen extends ConsumerWidget { actions.Action.cooperativeCancelInitiatedByPeer) { widgets.add(_buildNostrButton( 'CONFIRM CANCEL', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.cooperativeCancelAccepted, backgroundColor: AppTheme.red1, onPressed: () => ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), @@ -488,9 +445,7 @@ class TradeDetailScreen extends ConsumerWidget { if (tradeState.lastAction != actions.Action.rateReceived) { widgets.add(_buildNostrButton( 'RATE', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.rateReceived, backgroundColor: AppTheme.mostroGreen, onPressed: () => context.push('/rate_user/$orderId'), )); @@ -506,9 +461,7 @@ class TradeDetailScreen extends ConsumerWidget { // Both roles can cancel during in-progress state, similar to active widgets.add(_buildNostrButton( 'CANCEL', - ref: ref, - completeProvider: completeProvider, - errorProvider: errorProvider, + action: actions.Action.canceled, backgroundColor: AppTheme.red1, onPressed: () => ref.read(orderNotifierProvider(orderId).notifier).cancelOrder(), @@ -531,9 +484,7 @@ class TradeDetailScreen extends ConsumerWidget { /// Helper method to build a NostrResponsiveButton with common properties Widget _buildNostrButton( String label, { - required WidgetRef ref, - required StateProvider completeProvider, - required StateProvider errorProvider, + required actions.Action action, required VoidCallback onPressed, Color? backgroundColor, }) { @@ -544,8 +495,8 @@ class TradeDetailScreen extends ConsumerWidget { buttonStyle: ButtonStyleType.raised, width: 180, height: 48, - completionProvider: completeProvider, - errorProvider: errorProvider, + orderId: orderId, + action: action, onPressed: onPressed, showSuccessIndicator: true, timeout: const Duration(seconds: 30), diff --git a/lib/shared/widgets/nostr_responsive_button.dart b/lib/shared/widgets/nostr_responsive_button.dart index e3a8dfe5..ff4ff5cb 100644 --- a/lib/shared/widgets/nostr_responsive_button.dart +++ b/lib/shared/widgets/nostr_responsive_button.dart @@ -1,46 +1,22 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logger/logger.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart' as actions; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; enum ButtonStyleType { raised, outlined, text } /// A button specially designed for Nostr operations that shows loading state /// and handles the unique event-based nature of Nostr protocols. class NostrResponsiveButton extends ConsumerStatefulWidget { - /// Button text final String label; - - /// Button style type final ButtonStyleType buttonStyle; - - /// The operation to perform when the button is pressed final VoidCallback onPressed; - - /// A provider that tracks the state of the operation - should emit a value when operation completes - final StateProvider completionProvider; - - /// A provider that tracks if there was an error - final StateProvider errorProvider; - - /// Optional callback when operation completes successfully - final VoidCallback? onOperationComplete; - - /// Optional callback when the operation fails - final Function(String error)? onOperationError; - - /// How long to wait before timing out + final String orderId; + final actions.Action action; final Duration timeout; - - /// Default error message to show - final String defaultErrorMessage; - - /// Width of the button, if null it uses the parent's constraints final double? width; - - /// Height of the button, defaults to 48 final double height; - final bool showSuccessIndicator; const NostrResponsiveButton({ @@ -48,12 +24,9 @@ class NostrResponsiveButton extends ConsumerStatefulWidget { required this.label, required this.buttonStyle, required this.onPressed, - required this.completionProvider, - required this.errorProvider, - this.onOperationComplete, - this.onOperationError, - this.timeout = const Duration(seconds: 30), // Nostr operations can take longer - this.defaultErrorMessage = 'Operation failed. Please try again.', + required this.orderId, + required this.action, + this.timeout = const Duration(seconds: 30), this.width, this.height = 48, this.showSuccessIndicator = false, @@ -66,13 +39,8 @@ class NostrResponsiveButton extends ConsumerStatefulWidget { class _NostrResponsiveButtonState extends ConsumerState { bool _loading = false; bool _showSuccess = false; - final _logger = Logger(); Timer? _timeoutTimer; - - @override - void initState() { - super.initState(); - } + dynamic _lastSeenAction; @override void dispose() { @@ -85,129 +53,35 @@ class _NostrResponsiveButtonState extends ConsumerState { _loading = true; _showSuccess = false; }); - - // Reset state providers - ref.read(widget.completionProvider.notifier).state = false; - ref.read(widget.errorProvider.notifier).state = null; - - // Start the operation widget.onPressed(); - - // Start timeout timer + _timeoutTimer?.cancel(); _timeoutTimer = Timer(widget.timeout, _handleTimeout); } - + void _handleTimeout() { if (_loading) { - _logger.w('Operation timed out after ${widget.timeout.inSeconds} seconds'); setState(() { _loading = false; + _showSuccess = false; }); - - final errorMsg = 'Operation timed out. Please try again.'; - ref.read(widget.errorProvider.notifier).state = errorMsg; - - if (widget.onOperationError != null) { - widget.onOperationError!(errorMsg); - } else { - _showErrorSnackbar(errorMsg); - } - } - } - - void _showErrorSnackbar(String message) { - if (mounted) { - // Use a post-frame callback to avoid showing the SnackBar during build - WidgetsBinding.instance.addPostFrameCallback((_) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); - }); - } - } - - void _handleCompletion() { - _timeoutTimer?.cancel(); - - setState(() { - _loading = false; - if (widget.showSuccessIndicator) { - _showSuccess = true; - // Reset success indicator after a short delay - Future.delayed(const Duration(seconds: 2), () { - if (mounted) { - setState(() { - _showSuccess = false; - }); - } - }); - } - }); - - if (widget.onOperationComplete != null) { - widget.onOperationComplete!(); - } - } - - void _handleError(String error) { - _timeoutTimer?.cancel(); - - setState(() { - _loading = false; - }); - - if (widget.onOperationError != null) { - widget.onOperationError!(error); - } else { - _showErrorSnackbar(error); } - - // Auto-reset after a delay to allow for retries - Future.delayed(const Duration(milliseconds: 1500), () { - if (mounted) { - // Reset the button's internal state - setState(() { - _loading = false; - _showSuccess = false; - }); - } - }); - } - - @override - void didUpdateWidget(NostrResponsiveButton oldWidget) { - super.didUpdateWidget(oldWidget); - _checkForStateChanges(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _checkForStateChanges(); - } - - void _checkForStateChanges() { - // Use a post-frame callback to avoid state changes during build - WidgetsBinding.instance.addPostFrameCallback((_) { - // Check for completion - final isComplete = ref.read(widget.completionProvider); - if (isComplete && _loading && mounted) { - _handleCompletion(); - } - - // Check for errors - final error = ref.read(widget.errorProvider); - if (error != null && _loading && mounted) { - _handleError(error); - } - }); } @override Widget build(BuildContext context) { - // Just watch the providers to rebuild when they change - ref.watch(widget.completionProvider); - ref.watch(widget.errorProvider); + ref.listen>(mostroMessageStreamProvider(widget.orderId), (prev, next) { + next.whenData((msg) { + if (msg == null || msg.action == _lastSeenAction) return; + _lastSeenAction = msg.action; + if (!_loading) return; + if (msg.action == actions.Action.cantDo || msg.action == widget.action) { + setState(() { + _loading = false; + _showSuccess = widget.showSuccessIndicator && msg.action == widget.action; + }); + } + }); + }); Widget childWidget; if (_loading) { From a970c3ad6180325948f0c22724d3dbea8a589949 Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 29 Apr 2025 21:23:33 +1000 Subject: [PATCH 135/149] Rename NostrResponsiveButton to MostroReactiveButton and update references --- .../order/screens/add_order_screen.dart | 12 +++++------ .../trades/screens/trade_detail_screen.dart | 2 +- .../widgets/nostr_responsive_button.dart | 21 +++++++++++-------- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index d92e21ef..2296b83c 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:heroicons/heroicons.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/cant_do.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as nostr_action; import 'package:mostro_mobile/data/models/enums/order_type.dart'; import 'package:mostro_mobile/data/models/mostro_message.dart'; @@ -17,12 +16,11 @@ import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; import 'package:mostro_mobile/shared/widgets/nostr_responsive_button.dart'; import 'package:uuid/uuid.dart'; -import 'package:mostro_mobile/data/models/mostro_message.dart'; // Create a direct state provider tied to the order action/status -final orderActionStatusProvider = Provider.family, int>( - (ref, requestId) => ref.watch(addOrderEventsProvider(requestId)) -); +final orderActionStatusProvider = + Provider.family, int>( + (ref, requestId) => ref.watch(addOrderEventsProvider(requestId))); class AddOrderScreen extends ConsumerStatefulWidget { const AddOrderScreen({super.key}); @@ -460,7 +458,7 @@ class _AddOrderScreenState extends ConsumerState { child: const Text('CANCEL'), ), const SizedBox(width: 8.0), - NostrResponsiveButton( + MostroReactiveButton( label: 'SUBMIT', buttonStyle: ButtonStyleType.raised, width: 120, @@ -496,7 +494,7 @@ class _AddOrderScreenState extends ConsumerState { // Calculate the request ID the same way AddOrderNotifier does final requestId = notifier.requestId; - + // Store the current request ID for the button state providers setState(() { _currentRequestId = requestId; diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 8515b9e9..225b0005 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -490,7 +490,7 @@ class TradeDetailScreen extends ConsumerWidget { }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), - child: NostrResponsiveButton( + child: MostroReactiveButton( label: label, buttonStyle: ButtonStyleType.raised, width: 180, diff --git a/lib/shared/widgets/nostr_responsive_button.dart b/lib/shared/widgets/nostr_responsive_button.dart index ff4ff5cb..2e965fec 100644 --- a/lib/shared/widgets/nostr_responsive_button.dart +++ b/lib/shared/widgets/nostr_responsive_button.dart @@ -6,9 +6,9 @@ import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; enum ButtonStyleType { raised, outlined, text } -/// A button specially designed for Nostr operations that shows loading state -/// and handles the unique event-based nature of Nostr protocols. -class NostrResponsiveButton extends ConsumerStatefulWidget { +/// A button specially designed for reactive operations that shows loading state +/// and handles the unique event-based nature of the mostro protocol. +class MostroReactiveButton extends ConsumerStatefulWidget { final String label; final ButtonStyleType buttonStyle; final VoidCallback onPressed; @@ -19,7 +19,7 @@ class NostrResponsiveButton extends ConsumerStatefulWidget { final double height; final bool showSuccessIndicator; - const NostrResponsiveButton({ + const MostroReactiveButton({ super.key, required this.label, required this.buttonStyle, @@ -33,10 +33,10 @@ class NostrResponsiveButton extends ConsumerStatefulWidget { }); @override - ConsumerState createState() => _NostrResponsiveButtonState(); + ConsumerState createState() => _MostroReactiveButtonState(); } -class _NostrResponsiveButtonState extends ConsumerState { +class _MostroReactiveButtonState extends ConsumerState { bool _loading = false; bool _showSuccess = false; Timer? _timeoutTimer; @@ -69,15 +69,18 @@ class _NostrResponsiveButtonState extends ConsumerState { @override Widget build(BuildContext context) { - ref.listen>(mostroMessageStreamProvider(widget.orderId), (prev, next) { + ref.listen>(mostroMessageStreamProvider(widget.orderId), + (prev, next) { next.whenData((msg) { if (msg == null || msg.action == _lastSeenAction) return; _lastSeenAction = msg.action; if (!_loading) return; - if (msg.action == actions.Action.cantDo || msg.action == widget.action) { + if (msg.action == actions.Action.cantDo || + msg.action == widget.action) { setState(() { _loading = false; - _showSuccess = widget.showSuccessIndicator && msg.action == widget.action; + _showSuccess = + widget.showSuccessIndicator && msg.action == widget.action; }); } }); From c3bbb1981a212460a396b48098c3c7d6d3251651 Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 29 Apr 2025 21:29:45 +1000 Subject: [PATCH 136/149] Fix chat message deduplication and add sorting by creation timestamp --- .../chat/notifiers/chat_room_notifier.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index 6a6cd1f8..daaabaeb 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -42,14 +42,17 @@ class ChatRoomNotifier extends StateNotifier { (event) async { try { final chat = await event.mostroUnWrap(session.sharedKey!); - if (!state.messages.contains(chat)) { - state = state.copy( - messages: [ - ...state.messages, - chat, - ], - ); - } + // Deduplicate by message ID and always sort by createdAt + final allMessages = [ + ...state.messages, + chat, + ]; + // Use a map to deduplicate by event id + final deduped = { + for (var m in allMessages) m.id: m + }.values.toList(); + deduped.sort((a, b) => a.createdAt!.compareTo(b.createdAt!)); + state = state.copy(messages: deduped); } catch (e) { _logger.e(e); } From 0a6ad39b3a3d276057c0b737458e5f460e8e98f6 Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 29 Apr 2025 22:10:01 +1000 Subject: [PATCH 137/149] Add notification permissions and enhance notification settings for Android/iOS --- android/app/src/main/AndroidManifest.xml | 2 + lib/main.dart | 3 ++ lib/notifications/notification_service.dart | 17 ++++++- .../utils/notification_permission_helper.dart | 9 ++++ pubspec.lock | 48 +++++++++++++++++++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 ++ windows/flutter/generated_plugins.cmake | 1 + 8 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 lib/shared/utils/notification_permission_helper.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4b077d8e..b871ba6e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -46,6 +46,8 @@ + + diff --git a/lib/main.dart b/lib/main.dart index 0acec2cb..6b6c9103 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,11 +10,14 @@ import 'package:mostro_mobile/notifications/notification_service.dart'; import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; import 'package:mostro_mobile/shared/providers/providers.dart'; import 'package:mostro_mobile/shared/utils/biometrics_helper.dart'; +import 'package:mostro_mobile/shared/utils/notification_permission_helper.dart'; import 'package:shared_preferences/shared_preferences.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + await requestNotificationPermissionIfNeeded(); + final biometricsHelper = BiometricsHelper(); final sharedPreferences = SharedPreferencesAsync(); final secureStorage = const FlutterSecureStorage(); diff --git a/lib/notifications/notification_service.dart b/lib/notifications/notification_service.dart index 0b414278..80bf2941 100644 --- a/lib/notifications/notification_service.dart +++ b/lib/notifications/notification_service.dart @@ -30,11 +30,26 @@ Future showLocalNotification(NostrEvent event) async { android: AndroidNotificationDetails( 'mostro_channel', 'Mostro Notifications', + channelDescription: 'Notifications for Mostro trades and messages', importance: Importance.max, + priority: Priority.high, + visibility: NotificationVisibility.public, + playSound: true, + enableVibration: true, + ticker: 'ticker', + // Uncomment for heads-up notification, use with care: + // fullScreenIntent: true, + ), + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + // Optionally set interruption level for iOS 15+: + interruptionLevel: InterruptionLevel.critical, ), ); await notificationsPlugin.show( - 0, + event.id.hashCode, // Use unique ID for each event 'New Mostro Event', 'You have received a new message from Mostro', details, diff --git a/lib/shared/utils/notification_permission_helper.dart b/lib/shared/utils/notification_permission_helper.dart new file mode 100644 index 00000000..867d494a --- /dev/null +++ b/lib/shared/utils/notification_permission_helper.dart @@ -0,0 +1,9 @@ +import 'package:permission_handler/permission_handler.dart'; + +/// Requests notification permission at runtime (Android 13+/API 33+). +Future requestNotificationPermissionIfNeeded() async { + final status = await Permission.notification.status; + if (status.isDenied || status.isRestricted) { + await Permission.notification.request(); + } +} diff --git a/pubspec.lock b/pubspec.lock index 94dfe2ea..99231657 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1034,6 +1034,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "2d070d8684b68efb580a5997eb62f675e8a885ef0be6e754fb9ef489c177470f" + url: "https://pub.dev" + source: hosted + version: "12.0.0+1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f686ff77..78c9e7c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,6 +84,7 @@ dependencies: system_tray: ^2.0.3 path_provider: ^2.1.5 rxdart: ^0.28.0 + permission_handler: ^12.0.0+1 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8e7c1bdb..94cd0408 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalAuthPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SystemTrayPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SystemTrayPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 7853b67f..52df19df 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows local_auth_windows + permission_handler_windows system_tray ) From c3dd5543976f9b330f2bd22285c7769fdf407de2 Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 29 Apr 2025 22:20:54 +1000 Subject: [PATCH 138/149] Simplify reloadData by removing event clearing and empty list emission --- lib/data/repositories/open_orders_repository.dart | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 17de8dc8..b2886893 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -128,13 +128,6 @@ class OpenOrdersRepository implements OrderRepository { Future reloadData() async { _logger.i('Reloading repository data'); - // Clear existing events - _events.clear(); - // Then resubscribe for future updates _subscribeToOrders(); - // Emit empty list so UI updates immediately - if (!_eventStreamController.isClosed) { - _eventStreamController.add([]); - } } } From 9d5399b834b4b1203a56ff37ae0a96191a70436e Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 29 Apr 2025 23:00:46 +1000 Subject: [PATCH 139/149] Improve chat reliability and add event persistence with lifecycle management --- .../repositories/open_orders_repository.dart | 30 ++++++++++------ .../chat/notifiers/chat_room_notifier.dart | 35 ++++++++++++++++--- .../chat/notifiers/chat_rooms_notifier.dart | 27 ++++++++++++-- lib/services/lifecycle_manager.dart | 2 +- 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index b2886893..08d5eefa 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -17,7 +17,7 @@ class OpenOrdersRepository implements OrderRepository { Settings _settings; final StreamController> _eventStreamController = - StreamController.broadcast(); + StreamController>.broadcast(); final Map _events = {}; final _logger = Logger(); StreamSubscription? _subscription; @@ -28,7 +28,7 @@ class OpenOrdersRepository implements OrderRepository { // Subscribe to orders and initialize data _subscribeToOrders(); // Immediately emit current (possibly empty) cache so UI doesn't remain in loading state - _eventStreamController.add(_events.values.toList()); + _emitEvents(); } /// Subscribes to events matching the given filter. @@ -37,9 +37,9 @@ class OpenOrdersRepository implements OrderRepository { final filterTime = DateTime.now().subtract(Duration(hours: orderFilterDurationHours)); - var filter = NostrFilter( - kinds: const [orderEventKind], - authors: [_settings.mostroPublicKey], + + final filter = NostrFilter( + kinds: [orderEventKind], since: filterTime, ); @@ -49,8 +49,12 @@ class OpenOrdersRepository implements OrderRepository { _subscription = _nostrService.subscribeToEvents(request).listen((event) { if (event.type == 'order') { - _events[event.orderId!] = event; - _eventStreamController.add(_events.values.toList()); + final oldEvent = _events[event.orderId!]; + // Only emit if the event is new or changed + if (oldEvent == null || oldEvent != event) { + _events[event.orderId!] = event; + _emitEvents(); + } } else if (event.type == 'info' && event.pubkey == _settings.mostroPublicKey) { _logger.i('Mostro instance info loaded: $event'); @@ -58,9 +62,14 @@ class OpenOrdersRepository implements OrderRepository { } }, onError: (error) { _logger.e('Error in order subscription: $error'); + // Optionally, you could auto-resubscribe here if desired }); // Ensure listeners receive at least one snapshot right after (re)subscription + _emitEvents(); + } + + void _emitEvents() { if (!_eventStreamController.isClosed) { _eventStreamController.add(_events.values.toList()); } @@ -90,14 +99,14 @@ class OpenOrdersRepository implements OrderRepository { @override Future addOrder(NostrEvent order) { _events[order.id!] = order; - _eventStreamController.add(_events.values.toList()); + _emitEvents(); return Future.value(); } @override Future deleteOrder(String orderId) { _events.remove(orderId); - _eventStreamController.add(_events.values.toList()); + _emitEvents(); return Future.value(); } @@ -110,7 +119,7 @@ class OpenOrdersRepository implements OrderRepository { Future updateOrder(NostrEvent order) { if (_events.containsKey(order.id)) { _events[order.id!] = order; - _eventStreamController.add(_events.values.toList()); + _emitEvents(); } return Future.value(); } @@ -129,5 +138,6 @@ class OpenOrdersRepository implements OrderRepository { Future reloadData() async { _logger.i('Reloading repository data'); _subscribeToOrders(); + _emitEvents(); } } diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index daaabaeb..0209df9e 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -5,10 +5,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:mostro_mobile/data/models/chat_room.dart'; import 'package:mostro_mobile/data/models/nostr_event.dart'; +import 'package:mostro_mobile/services/lifecycle_manager.dart'; +import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; class ChatRoomNotifier extends StateNotifier { + /// Reload the chat room by re-subscribing to events. + void reload() { + subscription.cancel(); + subscribe(); + } + final _logger = Logger(); final String orderId; final Ref ref; @@ -26,7 +34,7 @@ class ChatRoomNotifier extends StateNotifier { _logger.e('Session is null'); return; } - if(session.sharedKey == null) { + if (session.sharedKey == null) { _logger.e('Shared key is null'); return; } @@ -37,10 +45,21 @@ class ChatRoomNotifier extends StateNotifier { final request = NostrRequest( filters: [filter], ); + + ref.read(lifecycleManagerProvider).addSubscription(filter); + subscription = ref.read(nostrServiceProvider).subscribeToEvents(request).listen( (event) async { try { + final eventStore = ref.read(eventStorageProvider); + + if (await eventStore.hasItem(event.id!)) return; + await eventStore.putItem( + event.id!, + event, + ); + final chat = await event.mostroUnWrap(session.sharedKey!); // Deduplicate by message ID and always sort by createdAt final allMessages = [ @@ -48,9 +67,7 @@ class ChatRoomNotifier extends StateNotifier { chat, ]; // Use a map to deduplicate by event id - final deduped = { - for (var m in allMessages) m.id: m - }.values.toList(); + final deduped = {for (var m in allMessages) m.id: m}.values.toList(); deduped.sort((a, b) => a.createdAt!.compareTo(b.createdAt!)); state = state.copy(messages: deduped); } catch (e) { @@ -62,8 +79,16 @@ class ChatRoomNotifier extends StateNotifier { Future sendMessage(String text) async { final session = ref.read(sessionProvider(orderId)); + if (session == null) { + _logger.e('Session is null'); + return; + } + if (session.sharedKey == null) { + _logger.e('Shared key is null'); + return; + } final event = NostrEvent.fromPartialData( - keyPairs: session!.tradeKey, + keyPairs: session.tradeKey, content: text, kind: 1, ); diff --git a/lib/features/chat/notifiers/chat_rooms_notifier.dart b/lib/features/chat/notifiers/chat_rooms_notifier.dart index d69e88dc..15e1c014 100644 --- a/lib/features/chat/notifiers/chat_rooms_notifier.dart +++ b/lib/features/chat/notifiers/chat_rooms_notifier.dart @@ -6,6 +6,20 @@ import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; class ChatRoomsNotifier extends StateNotifier> { + /// Reload all chat rooms by triggering their notifiers to resubscribe to events. + Future reloadAllChats() async { + for (final chat in state) { + try { + final notifier = ref.read(chatRoomsProvider(chat.orderId).notifier); + if (notifier.mounted) { + notifier.reload(); + } + } catch (e) { + _logger.e('Failed to reload chat for orderId ${chat.orderId}: $e'); + } + } + } + final SessionNotifier sessionNotifier; final Ref ref; final _logger = Logger(); @@ -15,12 +29,21 @@ class ChatRoomsNotifier extends StateNotifier> { } Future loadChats() async { - final sessions = ref.watch(sessionNotifierProvider.notifier).sessions; + final sessions = ref.read(sessionNotifierProvider.notifier).sessions; + if (sessions.isEmpty) { + _logger.i("No sessions yet, skipping chat load."); + return; + } try { - state = sessions.where((s) => s.peer != null).map((s) { + final chats = sessions.where((s) => s.peer != null).map((s) { final chat = ref.read(chatRoomsProvider(s.orderId!)); return chat; }).toList(); + if (chats.isNotEmpty) { + state = chats; + } else { + _logger.i("No chats found for sessions, keeping previous state."); + } } catch (e) { _logger.e(e); } diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart index 1deabd50..88d2f35e 100644 --- a/lib/services/lifecycle_manager.dart +++ b/lib/services/lifecycle_manager.dart @@ -72,7 +72,7 @@ class LifecycleManager extends WidgetsBindingObserver { // Reinitialize chat rooms _logger.i("Reloading chat rooms"); final chatRooms = ref.read(chatRoomsNotifierProvider.notifier); - await chatRooms.loadChats(); + await chatRooms.reloadAllChats(); // Force UI update for trades _logger.i("Invalidating providers to refresh UI"); From 8bb0f58a95a98f97604f045adf2321f829c4ca36 Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 30 Apr 2025 00:04:14 +1000 Subject: [PATCH 140/149] Replace event provider with mostro storage and remove duplicate event check --- .../chat/notifiers/chat_room_notifier.dart | 1 - .../screens/add_lightning_invoice_screen.dart | 121 +++++++++--------- 2 files changed, 64 insertions(+), 58 deletions(-) diff --git a/lib/features/chat/notifiers/chat_room_notifier.dart b/lib/features/chat/notifiers/chat_room_notifier.dart index 0209df9e..2e2305fb 100644 --- a/lib/features/chat/notifiers/chat_room_notifier.dart +++ b/lib/features/chat/notifiers/chat_room_notifier.dart @@ -54,7 +54,6 @@ class ChatRoomNotifier extends StateNotifier { try { final eventStore = ref.read(eventStorageProvider); - if (await eventStore.hasItem(event.id!)) return; await eventStore.putItem( event.id!, event, diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index c8bbaf41..634a68f1 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:mostro_mobile/core/app_theme.dart'; -import 'package:mostro_mobile/data/models/nostr_event.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart'; -import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; +import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart'; class AddLightningInvoiceScreen extends ConsumerStatefulWidget { @@ -25,65 +25,72 @@ class _AddLightningInvoiceScreenState @override Widget build(BuildContext context) { final orderId = widget.orderId; - final order = ref.watch(eventProvider(orderId)); + final mostroOrderAsync = ref.watch(mostroOrderStreamProvider(orderId)); - final amount = order?.amount; + return mostroOrderAsync.when( + data: (mostroMessage) { + final orderPayload = mostroMessage?.getPayload(); + final amount = orderPayload?.amount; - return Scaffold( - backgroundColor: AppTheme.dark1, - appBar: OrderAppBar(title: 'Add Lightning Invoice'), - body: Column( - children: [ - Expanded( - child: Container( - margin: const EdgeInsets.all(16), - padding: EdgeInsets.all(20), - decoration: BoxDecoration( - color: AppTheme.dark2, - borderRadius: BorderRadius.circular(20), + return Scaffold( + backgroundColor: AppTheme.dark1, + appBar: OrderAppBar(title: 'Add Lightning Invoice'), + body: Column( + children: [ + Expanded( + child: Container( + margin: const EdgeInsets.all(16), + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.dark2, + borderRadius: BorderRadius.circular(20), + ), + child: AddLightningInvoiceWidget( + controller: invoiceController, + onSubmit: () async { + final invoice = invoiceController.text.trim(); + if (invoice.isNotEmpty) { + final orderNotifier = ref + .read(orderNotifierProvider(widget.orderId).notifier); + try { + await orderNotifier.sendInvoice( + widget.orderId, invoice, amount); + context.go('/'); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('Failed to update invoice: ${e.toString()}'), + ), + ); + } + } + }, + onCancel: () async { + final orderNotifier = + ref.read(orderNotifierProvider(widget.orderId).notifier); + try { + await orderNotifier.cancelOrder(); + context.go('/'); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('Failed to cancel order: ${e.toString()}'), + ), + ); + } + }, + amount: amount!, + ), + ), ), - child: AddLightningInvoiceWidget( - controller: invoiceController, - onSubmit: () async { - final invoice = invoiceController.text.trim(); - if (invoice.isNotEmpty) { - final orderNotifier = ref - .read(orderNotifierProvider(widget.orderId).notifier); - try { - await orderNotifier.sendInvoice( - widget.orderId, invoice, int.parse(amount)); - context.go('/'); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text('Failed to update invoice: ${e.toString()}'), - ), - ); - } - } - }, - onCancel: () async { - final orderNotifier = - ref.read(orderNotifierProvider(widget.orderId).notifier); - try { - await orderNotifier.cancelOrder(); - context.go('/'); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text('Failed to cancel order: ${e.toString()}'), - ), - ); - } - }, - amount: int.parse(amount!), - ), - ), + ], ), - ], - ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, st) => Center(child: Text('Error: $e')), ); } } From 4c325a54b8c470f8f3926d0d2b06441f7fdd2853 Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 30 Apr 2025 00:23:57 +1000 Subject: [PATCH 141/149] Only request notifications on Android and filter sessions newer than 48 hours --- lib/main.dart | 7 +++++-- lib/shared/notifiers/session_notifier.dart | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 6b6c9103..65330b9e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -16,7 +18,9 @@ import 'package:shared_preferences/shared_preferences.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await requestNotificationPermissionIfNeeded(); + if (Platform.isAndroid) { + await requestNotificationPermissionIfNeeded(); + } final biometricsHelper = BiometricsHelper(); final sharedPreferences = SharedPreferencesAsync(); @@ -25,7 +29,6 @@ Future main() async { final mostroDatabase = await openMostroDatabase('mostro.db'); final eventsDatabase = await openMostroDatabase('events.db'); - final settings = SettingsNotifier(sharedPreferences); await settings.init(); diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index d9296b4d..d7876a17 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -32,8 +32,12 @@ class SessionNotifier extends StateNotifier> { Future init() async { final allSessions = await _storage.getAllSessions(); + final now = DateTime.now(); + final cutoff = now.subtract(const Duration(hours: 48)); for (final session in allSessions) { - _sessions[session.orderId!] = session; + if (session.startTime.isAfter(cutoff)) { + _sessions[session.orderId!] = session; + } } state = sessions; } From 2f07d2b55e59b96ffd01fdb21425f5a24a3ef8f6 Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 30 Apr 2025 09:59:49 +1000 Subject: [PATCH 142/149] Only subscribe to sessions created within the last 24 hours --- lib/services/mostro_service.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index c809d549..87d36130 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -26,9 +26,13 @@ class MostroService { } void init() { + final now = DateTime.now(); + final cutoff = now.subtract(const Duration(hours: 24)); final sessions = _sessionNotifier.sessions; for (final session in sessions) { - subscribe(session); + if (session.startTime.isAfter(cutoff)) { + subscribe(session); + } } } From c90865aad8489109e6b1b20596af59914f19d6f3 Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 30 Apr 2025 10:52:55 +1000 Subject: [PATCH 143/149] Refactor session updates and add terminal status checks for active orders --- .../notfiers/abstract_mostro_notifier.dart | 20 +++++----- lib/services/mostro_service.dart | 28 +++++++++++-- lib/shared/notifiers/session_notifier.dart | 16 ++++++-- lib/shared/providers/app_init_provider.dart | 39 +++++++++++++++++++ 4 files changed, 86 insertions(+), 17 deletions(-) diff --git a/lib/features/order/notfiers/abstract_mostro_notifier.dart b/lib/features/order/notfiers/abstract_mostro_notifier.dart index 02bf9c34..b765c49f 100644 --- a/lib/features/order/notfiers/abstract_mostro_notifier.dart +++ b/lib/features/order/notfiers/abstract_mostro_notifier.dart @@ -133,15 +133,13 @@ class AbstractMostroNotifier extends StateNotifier { // add seller tradekey to session // open chat final sessionProvider = ref.read(sessionNotifierProvider.notifier); - final session = sessionProvider.getSessionByOrderId(orderId); - if (session == null) { - logger.e('Session is null for order: $orderId'); - break; - } - session.peer = order.buyerTradePubkey != null + final peer = order.buyerTradePubkey != null ? Peer(publicKey: order.buyerTradePubkey!) : null; - sessionProvider.saveSession(session); + sessionProvider.updateSession( + orderId, + (s) => s.peer = peer, + ); final chat = ref.read(chatRoomsProvider(orderId).notifier); chat.subscribe(); break; @@ -161,9 +159,11 @@ class AbstractMostroNotifier extends StateNotifier { // add seller tradekey to session // open chat final sessionProvider = ref.read(sessionNotifierProvider.notifier); - final session = sessionProvider.getSessionByOrderId(orderId); - session?.peer = Peer(publicKey: order!.sellerTradePubkey!); - sessionProvider.saveSession(session!); + final peer = Peer(publicKey: order!.sellerTradePubkey!); + sessionProvider.updateSession( + orderId, + (s) => s.peer = peer, + ); final chat = ref.read(chatRoomsProvider(orderId).notifier); chat.subscribe(); break; diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index 87d36130..fa0ffb97 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -25,12 +25,32 @@ class MostroService { init(); } - void init() { + void init() async { final now = DateTime.now(); final cutoff = now.subtract(const Duration(hours: 24)); final sessions = _sessionNotifier.sessions; + final messageStorage = ref.read(mostroStorageProvider); + // Set of terminal statuses + const terminalStatuses = { + Status.canceled, + Status.cooperativelyCanceled, + Status.success, + Status.expired, + Status.canceledByAdmin, + Status.settledByAdmin, + Status.completedByAdmin, + }; for (final session in sessions) { if (session.startTime.isAfter(cutoff)) { + if (session.orderId != null) { + final latestOrderMsg = await messageStorage.getLatestMessageOfTypeById(session.orderId!); + final status = latestOrderMsg?.payload is Order + ? (latestOrderMsg!.payload as Order).status + : null; + if (status != null && terminalStatuses.contains(status)) { + continue; + } + } subscribe(session); } } @@ -81,8 +101,10 @@ class MostroService { } await messageStorage.addMessage(decryptedEvent.id!, msg); if (session.orderId == null && msg.id != null) { - session.orderId = msg.id; - await _sessionNotifier.saveSession(session); + await _sessionNotifier.updateSession( + session.orderId!, + (s) => s.orderId = msg.id, + ); } }); } diff --git a/lib/shared/notifiers/session_notifier.dart b/lib/shared/notifiers/session_notifier.dart index d7876a17..6b6a2e85 100644 --- a/lib/shared/notifiers/session_notifier.dart +++ b/lib/shared/notifiers/session_notifier.dart @@ -25,10 +25,7 @@ class SessionNotifier extends StateNotifier> { this._keyManager, this._storage, this._settings, - ) : super([]) { - //_init(); - //_initializeCleanup(); - } + ) : super([]); Future init() async { final allSessions = await _storage.getAllSessions(); @@ -77,6 +74,17 @@ class SessionNotifier extends StateNotifier> { state = sessions; } + /// Generic session update and persist method + Future updateSession( + String orderId, void Function(Session) update) async { + final session = _sessions[orderId]; + if (session != null) { + update(session); + await _storage.putSession(session); + state = sessions; + } + } + Session? getSessionByOrderId(String orderId) { try { return _sessions[orderId]; diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 1eb7fbff..bcf9404a 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -7,6 +7,8 @@ import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/order.dart'; import 'package:mostro_mobile/shared/notifiers/order_action_notifier.dart'; import 'package:mostro_mobile/shared/providers/background_service_provider.dart'; import 'package:mostro_mobile/shared/providers/mostro_service_provider.dart'; @@ -25,6 +27,43 @@ final appInitializerProvider = FutureProvider((ref) async { final sessionManager = ref.read(sessionNotifierProvider.notifier); await sessionManager.init(); + // --- Custom logic for initializing notifiers and chats --- + final now = DateTime.now(); + final cutoff = now.subtract(const Duration(hours: 24)); + final sessions = sessionManager.sessions; + final messageStorage = ref.read(mostroStorageProvider); + final terminalStatuses = { + Status.canceled, + Status.cooperativelyCanceled, + Status.success, + Status.expired, + Status.canceledByAdmin, + Status.settledByAdmin, + Status.completedByAdmin, + }; + for (final session in sessions) { + if (session.startTime.isAfter(cutoff)) { + bool isActive = true; + if (session.orderId != null) { + final latestOrderMsg = await messageStorage.getLatestMessageOfTypeById(session.orderId!); + final status = latestOrderMsg?.payload is Order + ? (latestOrderMsg!.payload as Order).status + : null; + if (status != null && terminalStatuses.contains(status)) { + isActive = false; + } + } + if (isActive) { + // Initialize order notifier if needed + ref.read(orderNotifierProvider(session.orderId!).notifier); + // Initialize chat notifier if needed + if (session.peer != null) { + ref.read(chatRoomsProvider(session.orderId!).notifier); + } + } + } + } + final mostroService = ref.read(mostroServiceProvider); ref.listen(settingsProvider, (previous, next) { From ce672bbf053fcf5c15f440f4c1d2341eae927240 Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 30 Apr 2025 11:19:54 +1000 Subject: [PATCH 144/149] Refactor button styles and remove hardcoded dimensions from reactive buttons --- lib/core/app_theme.dart | 2 +- .../order/screens/add_order_screen.dart | 1 - .../trades/screens/trade_detail_screen.dart | 21 +++++++------------ .../widgets/nostr_responsive_button.dart | 17 +++++++-------- 4 files changed, 17 insertions(+), 24 deletions(-) diff --git a/lib/core/app_theme.dart b/lib/core/app_theme.dart index 168aef68..6c48b055 100644 --- a/lib/core/app_theme.dart +++ b/lib/core/app_theme.dart @@ -57,7 +57,7 @@ class AppTheme { backgroundColor: mostroGreen, textStyle: GoogleFonts.robotoCondensed( fontWeight: FontWeight.w500, - fontSize: 16.0, + fontSize: 14.0, ), padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 30), ), diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index 2296b83c..aeb002cc 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -461,7 +461,6 @@ class _AddOrderScreenState extends ConsumerState { MostroReactiveButton( label: 'SUBMIT', buttonStyle: ButtonStyleType.raised, - width: 120, orderId: _currentRequestId?.toString() ?? '', action: nostr_action.Action.newOrder, onPressed: () { diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 225b0005..1a5f3e0a 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -488,19 +488,14 @@ class TradeDetailScreen extends ConsumerWidget { required VoidCallback onPressed, Color? backgroundColor, }) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: MostroReactiveButton( - label: label, - buttonStyle: ButtonStyleType.raised, - width: 180, - height: 48, - orderId: orderId, - action: action, - onPressed: onPressed, - showSuccessIndicator: true, - timeout: const Duration(seconds: 30), - ), + return MostroReactiveButton( + label: label, + buttonStyle: ButtonStyleType.raised, + orderId: orderId, + action: action, + onPressed: onPressed, + showSuccessIndicator: true, + timeout: const Duration(seconds: 30), ); } diff --git a/lib/shared/widgets/nostr_responsive_button.dart b/lib/shared/widgets/nostr_responsive_button.dart index 2e965fec..1b27bb8a 100644 --- a/lib/shared/widgets/nostr_responsive_button.dart +++ b/lib/shared/widgets/nostr_responsive_button.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mostro_mobile/data/models/enums/action.dart' as actions; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; enum ButtonStyleType { raised, outlined, text } @@ -15,8 +16,7 @@ class MostroReactiveButton extends ConsumerStatefulWidget { final String orderId; final actions.Action action; final Duration timeout; - final double? width; - final double height; + final bool showSuccessIndicator; const MostroReactiveButton({ @@ -27,8 +27,7 @@ class MostroReactiveButton extends ConsumerStatefulWidget { required this.orderId, required this.action, this.timeout = const Duration(seconds: 30), - this.width, - this.height = 48, + this.showSuccessIndicator = false, }); @@ -104,27 +103,27 @@ class _MostroReactiveButtonState extends ConsumerState { case ButtonStyleType.raised: button = ElevatedButton( onPressed: _loading ? null : _startOperation, + style: AppTheme.theme.elevatedButtonTheme.style, child: childWidget, ); break; case ButtonStyleType.outlined: button = OutlinedButton( onPressed: _loading ? null : _startOperation, + style: AppTheme.theme.outlinedButtonTheme.style, child: childWidget, ); break; case ButtonStyleType.text: button = TextButton( onPressed: _loading ? null : _startOperation, + style: AppTheme.theme.textButtonTheme.style, child: childWidget, ); break; } - return SizedBox( - width: widget.width, - height: widget.height, - child: button, - ); + return button; + } } From 0f39455cd6acba38ff1d047637fd6f8488e61620 Mon Sep 17 00:00:00 2001 From: Biz Date: Wed, 30 Apr 2025 23:22:20 +1000 Subject: [PATCH 145/149] Filter chats older than 36h and fix session orderId update logic --- .../chat/notifiers/chat_rooms_notifier.dart | 25 ++++++++++++------- lib/services/mostro_service.dart | 9 +++---- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/features/chat/notifiers/chat_rooms_notifier.dart b/lib/features/chat/notifiers/chat_rooms_notifier.dart index 15e1c014..5f6580ee 100644 --- a/lib/features/chat/notifiers/chat_rooms_notifier.dart +++ b/lib/features/chat/notifiers/chat_rooms_notifier.dart @@ -6,6 +6,14 @@ import 'package:mostro_mobile/shared/notifiers/session_notifier.dart'; import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; class ChatRoomsNotifier extends StateNotifier> { + final SessionNotifier sessionNotifier; + final Ref ref; + final _logger = Logger(); + + ChatRoomsNotifier(this.ref, this.sessionNotifier) : super(const []) { + loadChats(); + } + /// Reload all chat rooms by triggering their notifiers to resubscribe to events. Future reloadAllChats() async { for (final chat in state) { @@ -20,22 +28,21 @@ class ChatRoomsNotifier extends StateNotifier> { } } - final SessionNotifier sessionNotifier; - final Ref ref; - final _logger = Logger(); - - ChatRoomsNotifier(this.ref, this.sessionNotifier) : super(const []) { - loadChats(); - } - Future loadChats() async { final sessions = ref.read(sessionNotifierProvider.notifier).sessions; if (sessions.isEmpty) { _logger.i("No sessions yet, skipping chat load."); return; } + final now = DateTime.now(); + final cutoff = now.subtract(const Duration(hours: 36)); + try { - final chats = sessions.where((s) => s.peer != null).map((s) { + final chats = sessions + .where( + (s) => s.peer != null && s.startTime.isAfter(cutoff), + ) + .map((s) { final chat = ref.read(chatRoomsProvider(s.orderId!)); return chat; }).toList(); diff --git a/lib/services/mostro_service.dart b/lib/services/mostro_service.dart index fa0ffb97..98a55cf0 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -43,7 +43,8 @@ class MostroService { for (final session in sessions) { if (session.startTime.isAfter(cutoff)) { if (session.orderId != null) { - final latestOrderMsg = await messageStorage.getLatestMessageOfTypeById(session.orderId!); + final latestOrderMsg = await messageStorage + .getLatestMessageOfTypeById(session.orderId!); final status = latestOrderMsg?.payload is Order ? (latestOrderMsg!.payload as Order).status : null; @@ -101,10 +102,8 @@ class MostroService { } await messageStorage.addMessage(decryptedEvent.id!, msg); if (session.orderId == null && msg.id != null) { - await _sessionNotifier.updateSession( - session.orderId!, - (s) => s.orderId = msg.id, - ); + session.orderId = msg.id; + await _sessionNotifier.saveSession(session); } }); } From 4aca13c3d92663846ae5fa023ee767f33b915b45 Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 6 May 2025 06:42:26 -0700 Subject: [PATCH 146/149] Fix order notifier disposal and add Italian localization --- CONTRIBUTING.md | 120 +++++++++ README.md | 23 +- lib/background/background.dart | 10 + lib/background/background_service.dart | 7 +- .../desktop_background_service.dart | 3 + .../repositories/open_orders_repository.dart | 2 +- .../chat/notifiers/chat_rooms_notifier.dart | 2 +- .../order/notfiers/order_notifier.dart | 6 +- .../trades/screens/trades_screen.dart | 2 +- lib/generated/l10n.dart | 7 +- lib/generated/l10n_it.dart | 236 ++++++++++++++++++ lib/services/lifecycle_manager.dart | 4 +- macos/Podfile | 2 +- pubspec.lock | 48 ++-- 14 files changed, 424 insertions(+), 48 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 lib/generated/l10n_it.dart diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..2690150e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,120 @@ +# Contributing to Mostro Mobile + +Welcome! Thank you for your interest in contributing to the Mostro Mobile project. This guide will help you get started, understand the project architecture, and contribute effectively. + +--- + +## Table of Contents +- [How to Contribute](#how-to-contribute) +- [Project Architecture Overview](#project-architecture-overview) +- [Getting Started](#getting-started) +- [Best Practices & Tips](#best-practices--tips) +- [Contact & Further Resources](#contact--further-resources) + +--- + +## How to Contribute + +We follow the standard GitHub workflow: + +1. **Fork the repository** +2. **Create a new branch** for your feature or bugfix +3. **Write clear, descriptive commit messages** +4. **Push your branch** and open a Pull Request (PR) +5. **Describe your changes** thoroughly in the PR +6. **Participate in code review** and address feedback + +For bug reports and feature requests, please [open an issue](https://github.com/MostroP2P/mobile/issues) with: +- Clear title and description +- Steps to reproduce (for bugs) +- Screenshots/logs if relevant + +--- + +## Project Architecture Overview + +Mostro Mobile is a Flutter app designed with modularity and maintainability in mind. Here’s a high-level overview of the architecture and key technologies: + +### 1. State Management: Riverpod & Notifier Pattern +- **Riverpod** is used for dependency injection and state management. +- Providers are organized by feature, e.g., `features/order/providers/order_notifier_provider.dart`. +- The **Notifier pattern** is used for complex state logic (e.g., order submission, authentication). Notifiers encapsulate business logic and expose state via providers. +- Dedicated state providers are often used to track transient UI states, such as submission progress or error messages. + +### 2. Data Persistence: Sembast +- **Sembast** is a NoSQL database used for local data persistence. +- Database initialization is handled in `shared/providers/mostro_database_provider.dart`. +- Data repositories (e.g., `data/repositories/base_storage.dart`) abstract CRUD operations and encode/decode models. +- All local data access should go through repository classes, not direct database calls. + +### 3. Connectivity: NostrService +- Connectivity with the Nostr protocol is handled by `services/nostr_service.dart`. +- The `NostrService` manages relay connections, event subscriptions, and message handling. +- All Nostr-related logic should be routed through this service or its providers. + +### 4. Routing: GoRouter +- Navigation is managed using **GoRouter**. +- The main router configuration is set up in `core/app.dart` (see the `goRouter` instance). +- Route definitions are centralized for maintainability and deep linking. + +### 5. Internationalization (i18n) +- Internationalization is handled using the `intl` package and Flutter’s localization tools. +- Localization files are in `lib/l10n/` and generated code in `lib/generated/`. +- Use `S.of(context).yourKey` for all user-facing strings. + +### 6. Other Key Patterns & Utilities +- **Background Services:** Found in `lib/background/` for handling background tasks (e.g., notifications, data sync). +- **Notifications:** Managed via `notifications/` and corresponding providers. +- **Shared Utilities:** Common widgets, helpers, and providers live in `shared/`. +- **Testing:** Tests are in the `test/` and `integration_test/` directories. + +--- + +## Getting Started + +1. **Clone the repo:** + ```sh + git clone https://github.com/chebizarro/mobile.git + cd mobile + ``` +2. **Install dependencies:** + ```sh + flutter pub get + ``` +3. **Run the app:** + ```sh + flutter run + ``` +4. **Configure environment:** + - Localization: Run `flutter gen-l10n` if you add new strings. + - Platform-specific setup: See `README.md` for details. + +5. **Testing:** + - Unit tests: `flutter test` + - Integration tests: `flutter test integration_test/` + +6. **Linting & Formatting:** + - Check code style: `flutter analyze` + - Format code: `flutter format .` + +--- + +## Best Practices & Tips + +- **Follow the existing folder and provider structure.** +- **Use Riverpod for all state management.** Avoid mixing with other state solutions. +- **Encapsulate business logic in Notifiers** and expose state via providers. +- **Use repositories for all data access** (Sembast or Nostr). +- **Keep UI code declarative and side-effect free.** +- **For SnackBars, dialogs, or overlays,** always use a post-frame callback to avoid build-phase errors (see `NostrResponsiveButton` pattern). +- **Refer to existing features** (e.g., order submission, chat) for implementation examples. + +--- + +## Contact & Further Resources + +- **Main repo:** [https://github.com/MostroP2P/mobile](https://github.com/MostroP2P/mobile) +- **Questions/Help:** Open a GitHub issue or discussion +- **Docs:** See `README.md` and code comments for more details + +Happy contributing! diff --git a/README.md b/README.md index 4755a3f0..cd79f2bb 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ flutter run cargo run ``` +See the README.md in the mostro repository for more details. + ## Setting up Polar for Testing 1. Launch Polar and create a new Lightning Network. @@ -106,18 +108,17 @@ This project is licensed under the MIT License. See the `LICENSE` file for detai - [x] Displays order list - [x] Take orders (Buy & Sell) - [x] Posts Orders (Buy & Sell) -- [ ] Direct message with peers (use nip-17) -- [ ] Fiat sent -- [ ] Release -- [ ] Maker cancel pending order -- [ ] Cooperative cancellation +- [x] Direct message with peers +- [x] Fiat sent +- [x] Release +- [x] Maker cancel pending order +- [x] Cooperative cancellation - [ ] Buyer: add new invoice if payment fails -- [ ] Rate users -- [ ] List own orders +- [x] Rate users +- [x] List own orders - [ ] Dispute flow (users) - [ ] Dispute management (for admins) - [ ] Conversation key management -- [ ] Create buy orders with LN address -- [ ] Nip-06 support (identity management) -- [ ] Settings tab -- [ ] Notifications +- [x] Create buy orders with LN address +- [x] Settings tab +- [x] Notifications diff --git a/lib/background/background.dart b/lib/background/background.dart index b9877173..04476169 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -16,6 +16,10 @@ Future serviceMain(ServiceInstance service) async { // If on Android, set up a permanent notification so the OS won't kill it. if (service is AndroidServiceInstance) { service.setAsForegroundService(); + service.setForegroundNotificationInfo( + title: "Mostro P2P", + content: "Connected to Mostro service", + ); } final Map> activeSubscriptions = {}; @@ -49,6 +53,12 @@ Future serviceMain(ServiceInstance service) async { final request = NostrRequestX.fromJson(filters); final subscription = nostrService.subscribeToEvents(request); + + activeSubscriptions[request.subscriptionId!] = { + 'filters': filters, + 'subscription': subscription, + }; + subscription.listen((event) async { if (await eventStore.hasItem(event.id!)) return; await showLocalNotification(event); diff --git a/lib/background/background_service.dart b/lib/background/background_service.dart index 1d999046..9398a94b 100644 --- a/lib/background/background_service.dart +++ b/lib/background/background_service.dart @@ -1,14 +1,17 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:mostro_mobile/background/abstract_background_service.dart'; import 'package:mostro_mobile/background/desktop_background_service.dart'; import 'package:mostro_mobile/background/mobile_background_service.dart'; import 'package:mostro_mobile/features/settings/settings.dart'; - BackgroundService createBackgroundService(Settings settings) { + if (kIsWeb) { + throw UnsupportedError('Background services are not supported on web'); + } if (Platform.isAndroid || Platform.isIOS) { return MobileBackgroundService(settings); } else { - return DesktopBackgroundService(); + return DesktopBackgroundService(settings); } } diff --git a/lib/background/desktop_background_service.dart b/lib/background/desktop_background_service.dart index 583090be..1277d045 100644 --- a/lib/background/desktop_background_service.dart +++ b/lib/background/desktop_background_service.dart @@ -14,6 +14,9 @@ class DesktopBackgroundService implements BackgroundService { final _subscriptions = >{}; bool _isRunning = false; late SendPort _sendPort; + Settings _settings; + + DesktopBackgroundService(this._settings); @override Future init() async {} diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 08d5eefa..0b2de64a 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -135,7 +135,7 @@ class OpenOrdersRepository implements OrderRepository { } } - Future reloadData() async { + void reloadData() { _logger.i('Reloading repository data'); _subscribeToOrders(); _emitEvents(); diff --git a/lib/features/chat/notifiers/chat_rooms_notifier.dart b/lib/features/chat/notifiers/chat_rooms_notifier.dart index 5f6580ee..26de1aff 100644 --- a/lib/features/chat/notifiers/chat_rooms_notifier.dart +++ b/lib/features/chat/notifiers/chat_rooms_notifier.dart @@ -15,7 +15,7 @@ class ChatRoomsNotifier extends StateNotifier> { } /// Reload all chat rooms by triggering their notifiers to resubscribe to events. - Future reloadAllChats() async { + void reloadAllChats() { for (final chat in state) { try { final notifier = ref.read(chatRoomsProvider(chat.orderId).notifier); diff --git a/lib/features/order/notfiers/order_notifier.dart b/lib/features/order/notfiers/order_notifier.dart index b292876b..9138d3e1 100644 --- a/lib/features/order/notfiers/order_notifier.dart +++ b/lib/features/order/notfiers/order_notifier.dart @@ -82,9 +82,9 @@ class OrderNotifier extends AbstractMostroNotifier { @override void dispose() { - ref.read(cantDoNotifierProvider(orderId).notifier).dispose(); - ref.read(paymentNotifierProvider(orderId).notifier).dispose(); - ref.read(disputeNotifierProvider(orderId).notifier).dispose(); + ref.invalidate(cantDoNotifierProvider(orderId)); + ref.invalidate(paymentNotifierProvider(orderId)); + ref.invalidate(disputeNotifierProvider(orderId)); super.dispose(); } } diff --git a/lib/features/trades/screens/trades_screen.dart b/lib/features/trades/screens/trades_screen.dart index 88e8bf7f..eb343fef 100644 --- a/lib/features/trades/screens/trades_screen.dart +++ b/lib/features/trades/screens/trades_screen.dart @@ -26,7 +26,7 @@ class TradesScreen extends ConsumerWidget { body: RefreshIndicator( onRefresh: () async { // Force reload the orders repository first - await ref.read(orderRepositoryProvider).reloadData(); + ref.read(orderRepositoryProvider).reloadData(); // Then refresh the filtered trades provider ref.invalidate(filteredTradesProvider); }, diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 0ed88b5e..92039df8 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -6,6 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:intl/intl.dart' as intl; import 'l10n_en.dart'; +import 'l10n_it.dart'; // ignore_for_file: type=lint @@ -90,7 +91,8 @@ abstract class S { /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ - Locale('en') + Locale('en'), + Locale('it') ]; /// No description provided for @newOrder. @@ -439,7 +441,7 @@ class _SDelegate extends LocalizationsDelegate { } @override - bool isSupported(Locale locale) => ['en'].contains(locale.languageCode); + bool isSupported(Locale locale) => ['en', 'it'].contains(locale.languageCode); @override bool shouldReload(_SDelegate old) => false; @@ -451,6 +453,7 @@ S lookupS(Locale locale) { // Lookup logic when only language code is specified. switch (locale.languageCode) { case 'en': return SEn(); + case 'it': return SIt(); } throw FlutterError( diff --git a/lib/generated/l10n_it.dart b/lib/generated/l10n_it.dart new file mode 100644 index 00000000..8cf01ec3 --- /dev/null +++ b/lib/generated/l10n_it.dart @@ -0,0 +1,236 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'l10n.dart'; + +// ignore_for_file: type=lint + +/// The translations for Italian (`it`). +class SIt extends S { + SIt([String locale = 'it']) : super(locale); + + @override + String newOrder(Object expiration_hours) { + return 'La tua offerta è stata pubblicata! Attendi fino a quando un altro utente accetta il tuo ordine. Sarà disponibile per $expiration_hours ore. Puoi annullare questo ordine prima che un altro utente lo prenda premendo: Cancella.'; + } + + @override + String canceled(Object id) { + return 'Hai annullato l\'ordine ID: $id!'; + } + + @override + String payInvoice(Object amount, Object expiration_seconds, Object fiat_amount, Object fiat_code) { + return 'Paga questa Hodl Invoice di $amount Sats per $fiat_amount $fiat_code per iniziare l\'operazione. Se non la paghi entro $expiration_seconds lo scambio sarà annullato.'; + } + + @override + String addInvoice(Object amount, Object expiration_seconds, Object fiat_amount, Object fiat_code) { + return 'Inserisci una Invoice bolt11 di $amount satoshi equivalente a $fiat_amount $fiat_code. Questa invoice riceverà i fondi una volta completato lo scambio. Se non fornisci una invoice entro $expiration_seconds questo scambio sarà annullato.'; + } + + @override + String waitingSellerToPay(Object expiration_seconds, Object id) { + return 'Attendi un momento per favore. Ho inviato una richiesta di pagamento al venditore per rilasciare i Sats per l\'ordine ID $id. Una volta effettuato il pagamento, vi connetterò entrambi. Se il venditore non completa il pagamento entro $expiration_seconds minuti lo scambio sarà annullato.'; + } + + @override + String waitingBuyerInvoice(Object expiration_seconds) { + return 'Pagamento ricevuto! I tuoi Sats sono ora \'custoditi\' nel tuo portafoglio. Attendi un momento per favore. Ho richiesto all\'acquirente di fornire una invoice lightning. Una volta ricevuta, vi connetterò entrambi, altrimenti se non la riceviamo entro $expiration_seconds i tuoi Sats saranno nuovamente disponibili nel tuo portafoglio e lo scambio sarà annullato.'; + } + + @override + String get buyerInvoiceAccepted => 'Fattura salvata con successo!'; + + @override + String holdInvoicePaymentAccepted(Object fiat_amount, Object fiat_code, Object payment_method, Object seller_npub) { + return 'Contatta il venditore tramite la sua npub $seller_npub ed ottieni i dettagli per inviare il pagamento di $fiat_amount $fiat_code utilizzando $payment_method. Una volta inviato il pagamento, clicca: Pagamento inviato'; + } + + @override + String buyerTookOrder(Object buyer_npub, Object fiat_amount, Object fiat_code, Object payment_method) { + return 'Contatta l\'acquirente, ecco il suo npub $buyer_npub, per informarlo su come inviarti $fiat_amount $fiat_code tramite $payment_method. Riceverai una notifica una volta che l\'acquirente indicherà che il pagamento fiat è stato inviato. Successivamente, dovresti verificare se sono arrivati i fondi. Se l\'acquirente non risponde, puoi iniziare l\'annullamento dell\'ordine o una disputa. Ricorda, un amministratore NON ti contatterà per risolvere il tuo ordine a meno che tu non apra prima una disputa.'; + } + + @override + String fiatSentOkBuyer(Object seller_npub) { + return 'Ho informato $seller_npub che hai inviato il pagamento. Quando il venditore confermerà di aver ricevuto il tuo pagamento, dovrebbe rilasciare i fondi. Se rifiuta, puoi aprire una disputa.'; + } + + @override + String fiatSentOkSeller(Object buyer_npub) { + return '$buyer_npub ha confermato di aver inviato il pagamento. Una volta confermato la ricezione dei fondi fiat, puoi rilasciare i Sats. Dopo il rilascio, i Sats andranno all\'acquirente, l\'azione è irreversibile, quindi procedi solo se sei sicuro. Se vuoi rilasciare i Sats all\'acquirente, premi: Rilascia Fondi.'; + } + + @override + String released(Object seller_npub) { + return '$seller_npub ha già rilasciato i Sats! Aspetta solo che la tua invoice venga pagata. Ricorda, il tuo portafoglio deve essere online per ricevere i fondi tramite Lightning Network.'; + } + + @override + String get purchaseCompleted => 'L\'acquisto di nuovi satoshi è stato completato con successo. Goditi questi dolci Sats!'; + + @override + String holdInvoicePaymentSettled(Object buyer_npub) { + return 'Il tuo scambio di vendita di Sats è stato completato dopo aver confermato il pagamento da $buyer_npub.'; + } + + @override + String get rate => 'Dai una valutazione alla controparte'; + + @override + String get rateReceived => 'Valutazione salvata con successo!'; + + @override + String cooperativeCancelInitiatedByYou(Object id) { + return 'Hai iniziato l\'annullamento dell\'ordine ID: $id. La tua controparte deve anche concordare l\'annullamento. Se non risponde, puoi aprire una disputa. Nota che nessun amministratore ti contatterà MAI riguardo questo annullamento a meno che tu non apra prima una disputa.'; + } + + @override + String cooperativeCancelInitiatedByPeer(Object id) { + return 'La tua controparte vuole annullare l\'ordine ID: $id. Nota che nessun amministratore ti contatterà MAI riguardo questo annullamento a meno che tu non apra prima una disputa. Se concordi su tale annullamento, premi: Annulla Ordine.'; + } + + @override + String cooperativeCancelAccepted(Object id) { + return 'L\'ordine $id è stato annullato con successo!'; + } + + @override + String disputeInitiatedByYou(Object id, Object user_token) { + return 'Hai iniziato una disputa per l\'ordine ID: $id. Un amministratore sarà assegnato presto alla tua disputa. Una volta assegnato, riceverai il suo npub e solo questo account potrà assisterti. Devi contattare l\'amministratore direttamente, ma se qualcuno ti contatta prima, assicurati di chiedergli di fornirti il token per la tua disputa. Il token di questa disputa è: $user_token.'; + } + + @override + String disputeInitiatedByPeer(Object id, Object user_token) { + return 'La tua controparte ha iniziato una disputa per l\'ordine ID: $id. Un amministratore sarà assegnato presto alla tua disputa. Una volta assegnato, ti condividerò il loro npub e solo loro potranno assisterti. Devi contattare l\'amministratore direttamente, ma se qualcuno ti contatta prima, assicurati di chiedergli di fornirti il token per la tua disputa. Il token di questa disputa è: $user_token.'; + } + + @override + String adminTookDisputeAdmin(Object details) { + return 'Ecco i dettagli dell\'ordine della disputa che hai preso: $details. Devi determinare quale utente ha ragione e decidere se annullare o completare l\'ordine. Nota che la tua decisione sarà finale e non può essere annullata.'; + } + + @override + String adminTookDisputeUsers(Object admin_npub) { + return 'L\'amministratore $admin_npub gestirà la tua disputa. Devi contattare l\'amministratore direttamente, ma se qualcuno ti contatta prima, assicurati di chiedergli di fornirti il token per la tua disputa..'; + } + + @override + String adminCanceledAdmin(Object id) { + return 'Hai annullato l\'ordine ID: $id!'; + } + + @override + String adminCanceledUsers(Object id) { + return 'L\'amministratore ha annullato l\'ordine ID: $id!'; + } + + @override + String adminSettledAdmin(Object id) { + return 'Hai completato l\'ordine ID: $id!'; + } + + @override + String adminSettledUsers(Object id) { + return 'L\'amministratore ha completato l\'ordine ID: $id!'; + } + + @override + String paymentFailed(Object payment_attempts, Object payment_retries_interval) { + return 'Ho provato a inviarti i Sats ma il pagamento della tua invoice è fallito. Tenterò $payment_attempts volte ancora ogni $payment_retries_interval minuti. Per favore assicurati che il tuo nodo/portafoglio lightning sia online.'; + } + + @override + String get invoiceUpdated => 'Invoice aggiornata con successo!'; + + @override + String get holdInvoicePaymentCanceled => 'Invoice annullata; i tuoi Sats saranno nuovamente disponibili nel tuo portafoglio.'; + + @override + String cantDo(Object action) { + return 'Non sei autorizzato a $action per questo ordine!'; + } + + @override + String adminAddSolver(Object npub) { + return 'Hai aggiunto con successo l\'amministratore $npub.'; + } + + @override + String get invalidSignature => 'L\'azione non può essere completata perché la firma non è valida.'; + + @override + String get invalidTradeIndex => 'L\'indice di scambio fornito non è valido. Assicurati che il tuo client sia sincronizzato e riprova.'; + + @override + String get invalidAmount => 'L\'importo fornito non è valido. Verificalo e riprova.'; + + @override + String get invalidInvoice => 'La fattura Lightning fornita non è valida. Controlla i dettagli della fattura e riprova.'; + + @override + String get invalidPaymentRequest => 'La richiesta di pagamento non è valida o non può essere elaborata.'; + + @override + String get invalidPeer => 'Non sei autorizzato ad eseguire questa azione.'; + + @override + String get invalidRating => 'Il valore della valutazione è non valido o fuori dal range consentito.'; + + @override + String get invalidTextMessage => 'Il messaggio di testo non è valido o contiene contenuti proibiti.'; + + @override + String get invalidOrderKind => 'Il tipo di ordine non è valido.'; + + @override + String get invalidOrderStatus => 'L\'azione non può essere completata a causa dello stato attuale dell\'ordine.'; + + @override + String get invalidPubkey => 'L\'azione non può essere completata perché la chiave pubblica non è valida.'; + + @override + String get invalidParameters => 'L\'azione non può essere completata a causa di parametri non validi. Rivedi i valori forniti e riprova.'; + + @override + String get orderAlreadyCanceled => 'L\'azione non può essere completata perché l\'ordine è già stato annullato.'; + + @override + String get cantCreateUser => 'L\'azione non può essere completata perché l\'utente non può essere creato.'; + + @override + String get isNotYourOrder => 'Questo ordine non appartiene a te.'; + + @override + String notAllowedByStatus(Object id, Object order_status) { + return 'Non sei autorizzato ad eseguire questa azione perché lo stato dell\'ordine ID $id è $order_status.'; + } + + @override + String outOfRangeFiatAmount(Object max_amount, Object min_amount) { + return 'L\'importo richiesto è errato e potrebbe essere fuori dai limiti accettabili. Il limite minimo è $min_amount e il limite massimo è $max_amount.'; + } + + @override + String outOfRangeSatsAmount(Object max_order_amount, Object min_order_amount) { + return 'L\'importo consentito per gli ordini di questo Mostro è compreso tra min $min_order_amount e max $max_order_amount Sats. Inserisci un importo all\'interno di questo range.'; + } + + @override + String get isNotYourDispute => 'Questa disputa non è assegnata a te!'; + + @override + String get disputeCreationError => 'Non è possibile avviare una disputa per questo ordine.'; + + @override + String get notFound => 'Disputa non trovata.'; + + @override + String get invalidDisputeStatus => 'Lo stato della disputa è invalido.'; + + @override + String get invalidAction => 'L\'azione richiesta è invalida'; + + @override + String get pendingOrderExists => 'Esiste già un ordine in attesa.'; +} diff --git a/lib/services/lifecycle_manager.dart b/lib/services/lifecycle_manager.dart index 88d2f35e..82f91bff 100644 --- a/lib/services/lifecycle_manager.dart +++ b/lib/services/lifecycle_manager.dart @@ -67,12 +67,12 @@ class LifecycleManager extends WidgetsBindingObserver { // Refresh order repository by re-reading it _logger.i("Refreshing order repository"); final orderRepo = ref.read(orderRepositoryProvider); - await orderRepo.reloadData(); + orderRepo.reloadData(); // Reinitialize chat rooms _logger.i("Reloading chat rooms"); final chatRooms = ref.read(chatRoomsNotifierProvider.notifier); - await chatRooms.reloadAllChats(); + chatRooms.reloadAllChats(); // Force UI update for trades _logger.i("Invalidating providers to refresh UI"); diff --git a/macos/Podfile b/macos/Podfile index 29c8eb32..a46f7f23 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '11.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/pubspec.lock b/pubspec.lock index 3370fcf6..ae2615ae 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -34,10 +34,10 @@ packages: dependency: transitive description: name: archive - sha256: a7f37ff061d7abc2fcf213554b9dcaca713c5853afa5c065c44888bc9ccaf813 + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" url: "https://pub.dev" source: hosted - version: "4.0.6" + version: "4.0.7" args: dependency: transitive description: @@ -266,10 +266,10 @@ packages: dependency: transitive description: name: coverage - sha256: "9086475ef2da7102a0c0a4e37e1e30707e7fb7b6d28c209f559a9c5f8ce42016" + sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.13.1" crypto: dependency: "direct main" description: @@ -561,10 +561,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" + sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e url: "https://pub.dev" source: hosted - version: "2.0.27" + version: "2.0.28" flutter_riverpod: dependency: "direct main" description: @@ -672,10 +672,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "4cdfcc6a178632d1dbb7a728f8e84a1466211354704b9cdc03eee661d3277732" + sha256: "2b9ba6d4c247457c35a6622f1dee6aab6694a4e15237ff7c32320345044112b6" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.1.1" google_fonts: dependency: "direct main" description: @@ -853,10 +853,10 @@ packages: dependency: transitive description: name: local_auth_android - sha256: "0abe4e72f55c785b28900de52a2522c86baba0988838b5dc22241b072ecccd74" + sha256: "63ad7ca6396290626dc0cb34725a939e4cfe965d80d36112f08d49cf13a8136e" url: "https://pub.dev" source: hosted - version: "1.0.48" + version: "1.0.49" local_auth_darwin: dependency: transitive description: @@ -896,8 +896,8 @@ packages: sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "2.5.0" - logging: + version: "1.3.0" + macros: dependency: transitive description: name: macros @@ -998,10 +998,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 url: "https://pub.dev" source: hosted - version: "2.2.16" + version: "2.2.17" path_provider_foundation: dependency: transitive description: @@ -1254,10 +1254,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: c2c8c46297b5d6a80bed7741ec1f2759742c77d272f1a1698176ae828f8e1a18 + sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" url: "https://pub.dev" source: hosted - version: "2.4.9" + version: "2.4.10" shared_preferences_foundation: dependency: transitive description: @@ -1326,10 +1326,10 @@ packages: dependency: transitive description: name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "2.0.1" sky_engine: dependency: transitive description: flutter @@ -1339,10 +1339,10 @@ packages: dependency: transitive description: name: source_gen - sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "1.5.0" source_map_stack_trace: dependency: transitive description: @@ -1500,7 +1500,7 @@ packages: description: path: app_sembast ref: dart3a - resolved-ref: "329c102aec5086bbca15665f86267784405b5d81" + resolved-ref: "7c0aac5f8ec557a4fe975798600c47e4dcce0538" url: "https://github.com/tekartik/app_flutter_utils.dart" source: git version: "0.5.1" @@ -1509,7 +1509,7 @@ packages: description: path: app_sqflite ref: dart3a - resolved-ref: "329c102aec5086bbca15665f86267784405b5d81" + resolved-ref: "7c0aac5f8ec557a4fe975798600c47e4dcce0538" url: "https://github.com/tekartik/app_flutter_utils.dart" source: git version: "0.6.1" @@ -1575,10 +1575,10 @@ packages: dependency: transitive description: name: timezone - sha256: ffc9d5f4d1193534ef051f9254063fa53d588609418c84299956c3db9383587d + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 url: "https://pub.dev" source: hosted - version: "0.10.0" + version: "0.10.1" timing: dependency: transitive description: From 906e3bb7858fe772189c04b9d8226d3387f7a1d5 Mon Sep 17 00:00:00 2001 From: Biz Date: Tue, 6 May 2025 23:57:53 -0700 Subject: [PATCH 147/149] Refactor background service and storage management; remove unused repositories and improve button functionality --- .github/workflows/main.yml | 3 - lib/background/background.dart | 12 +++ lib/background/mobile_background_service.dart | 11 +++ lib/data/models/enums/storage_keys.dart | 4 +- lib/data/repositories/base_storage.dart | 49 +++++++--- lib/data/repositories/chat_repository.dart | 4 - .../repositories/open_orders_repository.dart | 11 +-- lib/data/repositories/order_storage.dart | 95 ------------------- lib/features/key_manager/key_storage.dart | 34 +++++-- .../foreground_service_controller.dart | 23 ----- .../order/screens/add_order_screen.dart | 2 +- .../trades/screens/trade_detail_screen.dart | 5 +- .../trades/screens/trades_screen.dart | 38 ++++---- .../providers/mostro_storage_provider.dart | 5 - lib/shared/utils/tray_manager.dart | 79 +++++++++------ ...utton.dart => mostro_reactive_button.dart} | 6 +- 16 files changed, 162 insertions(+), 219 deletions(-) delete mode 100644 lib/data/repositories/chat_repository.dart delete mode 100644 lib/data/repositories/order_storage.dart delete mode 100644 lib/features/notifications/foreground_service_controller.dart rename lib/shared/widgets/{nostr_responsive_button.dart => mostro_reactive_button.dart} (96%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0cf9a990..78f405ec 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,9 +4,6 @@ on: pull_request: branches: - main - push: - branches: - - wip jobs: build: diff --git a/lib/background/background.dart b/lib/background/background.dart index 04476169..bcb1b533 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -43,6 +43,18 @@ Future serviceMain(ServiceInstance service) async { service.invoke('service-ready', {}); }); + service.on('update-settings').listen((data) async { + if (data == null) return; + + final settingsMap = data['settings']; + if (settingsMap == null) return; + + final settings = Settings.fromJson(settingsMap); + await nostrService.updateSettings(settings); + + service.invoke('service-ready', {}); + }); + service.on('create-subscription').listen((data) { if (data == null || data['filters'] == null) return; diff --git a/lib/background/mobile_background_service.dart b/lib/background/mobile_background_service.dart index 344d1695..5f290646 100644 --- a/lib/background/mobile_background_service.dart +++ b/lib/background/mobile_background_service.dart @@ -142,7 +142,13 @@ class MobileBackgroundService implements BackgroundService { _serviceReady = false; // Reset ready state when starting // Wait for the service to be running + const maxWait = Duration(seconds: 5); + final deadline = DateTime.now().add(maxWait); + while (!(await service.isRunning())) { + if (DateTime.now().isAfter(deadline)) { + throw StateError('Background service failed to start within $maxWait'); + } await Future.delayed(const Duration(milliseconds: 50)); } @@ -160,6 +166,11 @@ class MobileBackgroundService implements BackgroundService { @override void updateSettings(Settings settings) { _settings = settings; + _executeWhenReady(() { + service.invoke('update-settings', { + 'settings': settings.toJson(), + }); + }); } @override diff --git a/lib/data/models/enums/storage_keys.dart b/lib/data/models/enums/storage_keys.dart index 6ed42b84..de24fcc8 100644 --- a/lib/data/models/enums/storage_keys.dart +++ b/lib/data/models/enums/storage_keys.dart @@ -27,9 +27,7 @@ enum SharedPreferencesKeys { enum SecureStorageKeys { masterKey('master_key'), - mnemonic('mnemonic'), - message('message-'), - sessionKey('session-'); + mnemonic('mnemonic'); final String value; diff --git a/lib/data/repositories/base_storage.dart b/lib/data/repositories/base_storage.dart index a771f173..19267520 100644 --- a/lib/data/repositories/base_storage.dart +++ b/lib/data/repositories/base_storage.dart @@ -1,7 +1,7 @@ import 'package:sembast/sembast.dart'; /// Base repository -/// +/// A base interface for a Sembast-backed storage of items of type [T]. /// Sub-class must implement: /// • `toDbMap` → encode T → Map /// • `fromDbMap` → decode Map → T @@ -11,11 +11,19 @@ abstract class BaseStorage { BaseStorage(this.db, this.store); + /// Convert a domain object [T] to JSON-ready Map. Map toDbMap(T item); - T fromDbMap(String key, Map json); - Future putItem(String id, T item) => - store.record(id).put(db, toDbMap(item)); + /// Decode a JSON Map into domain object [T]. + T fromDbMap(String key, Map jsonMap); + + /// Insert or update an item in the store. The item is identified by [id]. + Future putItem(String id, T item) async { + final jsonMap = toDbMap(item); + await db.transaction((txn) async { + await store.record(id).put(txn, jsonMap); + }); + } Future getItem(String id) async { final json = await store.record(id).get(db); @@ -24,13 +32,29 @@ abstract class BaseStorage { Future hasItem(String id) => store.record(id).exists(db); - Future deleteItem(String id) => store.record(id).delete(db); + /// Delete an item by [id]. + Future deleteItem(String id) async { + await db.transaction((txn) async { + await store.record(id).delete(txn); + }); + } - Future deleteAll() => store.delete(db); + /// Delete all items in the store. + Future deleteAll() async { + await db.transaction((txn) async { + await store.delete(txn); + }); + } /// Delete by arbitrary Sembast [Filter]. - Future deleteWhere(Filter filter) => - store.delete(db, finder: Finder(filter: filter)); + Future deleteWhere(Filter filter) async { + return await db.transaction((txn) async { + return await store.delete( + db, + finder: Finder(filter: filter), + ); + }); + } Future> find({ Filter? filter, @@ -61,11 +85,8 @@ abstract class BaseStorage { final query = store.query( finder: Finder(filter: filter, sortOrders: sort), ); - return query - .onSnapshots(db) - .map((snaps) => snaps - .map((s) => fromDbMap(s.key, s.value)) - .toList(growable: false)); + return query.onSnapshots(db).map((snaps) => + snaps.map((s) => fromDbMap(s.key, s.value)).toList(growable: false)); } /// Watch a single record by its [id] – emits *null* when deleted. @@ -76,8 +97,6 @@ abstract class BaseStorage { .map((snap) => snap == null ? null : fromDbMap(id, snap.value)); } - // ────────────────────────── Convenience helpers ──────────────── /// Equality filter on a given [field] (`x == value`) Filter eq(String field, Object? value) => Filter.equals(field, value); - } diff --git a/lib/data/repositories/chat_repository.dart b/lib/data/repositories/chat_repository.dart deleted file mode 100644 index 198f9364..00000000 --- a/lib/data/repositories/chat_repository.dart +++ /dev/null @@ -1,4 +0,0 @@ -class ChatRepository { - - -} \ No newline at end of file diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 0b2de64a..5dca74f7 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -17,7 +17,7 @@ class OpenOrdersRepository implements OrderRepository { Settings _settings; final StreamController> _eventStreamController = - StreamController>.broadcast(); + StreamController.broadcast(); final Map _events = {}; final _logger = Logger(); StreamSubscription? _subscription; @@ -41,6 +41,7 @@ class OpenOrdersRepository implements OrderRepository { final filter = NostrFilter( kinds: [orderEventKind], since: filterTime, + authors: [_settings.mostroPublicKey], ); final request = NostrRequest( @@ -49,12 +50,8 @@ class OpenOrdersRepository implements OrderRepository { _subscription = _nostrService.subscribeToEvents(request).listen((event) { if (event.type == 'order') { - final oldEvent = _events[event.orderId!]; - // Only emit if the event is new or changed - if (oldEvent == null || oldEvent != event) { - _events[event.orderId!] = event; - _emitEvents(); - } + _events[event.orderId!] = event; + _eventStreamController.add(_events.values.toList()); } else if (event.type == 'info' && event.pubkey == _settings.mostroPublicKey) { _logger.i('Mostro instance info loaded: $event'); diff --git a/lib/data/repositories/order_storage.dart b/lib/data/repositories/order_storage.dart deleted file mode 100644 index 5acb2465..00000000 --- a/lib/data/repositories/order_storage.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:async'; -import 'package:logger/logger.dart'; -import 'package:mostro_mobile/data/models/order.dart'; -import 'package:mostro_mobile/data/repositories/base_storage.dart'; -import 'package:sembast/sembast.dart'; - -class OrderStorage extends BaseStorage { - final Logger _logger = Logger(); - - OrderStorage({ - required Database db, - }) : super( - db, - stringMapStoreFactory.store('orders'), - ); - - Future init() async { - await getAllOrders(); - } - - /// Save or update an Order - Future addOrder(Order order) async { - final orderId = order.id; - if (orderId == null) { - throw ArgumentError('Cannot save an order with a null order.id'); - } - - try { - await putItem(orderId, order); - _logger.i('Order $orderId saved'); - } catch (e, stack) { - _logger.e('addOrder failed for $orderId', error: e, stackTrace: stack); - rethrow; - } - } - - Future addOrders(List orders) async { - for (final order in orders) { - addOrder(order); - } - } - - /// Retrieve an order by ID - Future getOrderById(String orderId) async { - try { - return await getItem(orderId); - } catch (e, stack) { - _logger.e('Error deserializing order $orderId', - error: e, stackTrace: stack); - return null; - } - } - - /// Return all orders - Future> getAllOrders() async { - try { - return await getAll(); - } catch (e, stack) { - _logger.e('getAllOrders failed', error: e, stackTrace: stack); - return []; - } - } - - /// Delete an order from DB - Future deleteOrder(String orderId) async { - try { - await deleteItem(orderId); - _logger.i('Order $orderId deleted from DB'); - } catch (e, stack) { - _logger.e('deleteOrder failed for $orderId', error: e, stackTrace: stack); - rethrow; - } - } - - /// Delete all orders - Future deleteAllOrders() async { - try { - await deleteAll(); - _logger.i('All orders deleted'); - } catch (e, stack) { - _logger.e('deleteAllOrders failed', error: e, stackTrace: stack); - rethrow; - } - } - - @override - Order fromDbMap(String key, Map jsonMap) { - return Order.fromJson(jsonMap); - } - - @override - Map toDbMap(Order item) { - return item.toJson(); - } -} diff --git a/lib/features/key_manager/key_storage.dart b/lib/features/key_manager/key_storage.dart index a478bf53..2df86888 100644 --- a/lib/features/key_manager/key_storage.dart +++ b/lib/features/key_manager/key_storage.dart @@ -6,28 +6,48 @@ class KeyStorage { final FlutterSecureStorage secureStorage; final SharedPreferencesAsync sharedPrefs; - KeyStorage({required this.secureStorage, required this.sharedPrefs}); + KeyStorage({ + required this.secureStorage, + required this.sharedPrefs, + }); + Future storeMasterKey(String masterKey) async { - await secureStorage.write(key: SecureStorageKeys.masterKey.value, value: masterKey); + await secureStorage.write( + key: SecureStorageKeys.masterKey.value, + value: masterKey, + ); } Future readMasterKey() async { - return secureStorage.read(key: SecureStorageKeys.masterKey.value); + return secureStorage.read( + key: SecureStorageKeys.masterKey.value, + ); } Future storeMnemonic(String mnemonic) async { - await secureStorage.write(key: SecureStorageKeys.mnemonic.value, value: mnemonic); + await secureStorage.write( + key: SecureStorageKeys.mnemonic.value, + value: mnemonic, + ); } Future readMnemonic() async { - return secureStorage.read(key: SecureStorageKeys.mnemonic.value); + return secureStorage.read( + key: SecureStorageKeys.mnemonic.value, + ); } Future storeTradeKeyIndex(int index) async { - await sharedPrefs.setInt(SharedPreferencesKeys.keyIndex.value, index); + await sharedPrefs.setInt( + SharedPreferencesKeys.keyIndex.value, + index, + ); } Future readTradeKeyIndex() async { - return await sharedPrefs.getInt(SharedPreferencesKeys.keyIndex.value) ?? 1; + return await sharedPrefs.getInt( + SharedPreferencesKeys.keyIndex.value, + ) ?? + 1; } } diff --git a/lib/features/notifications/foreground_service_controller.dart b/lib/features/notifications/foreground_service_controller.dart deleted file mode 100644 index 8827163b..00000000 --- a/lib/features/notifications/foreground_service_controller.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:logger/logger.dart'; - -class ForegroundServiceController { - static const _channel = MethodChannel('com.example.myapp/foreground_service'); - static final _logger = Logger(); - - static Future startService() async { - try { - await _channel.invokeMethod('startService'); - } on PlatformException catch (e) { - _logger.e("Failed to start service: ${e.message}"); - } - } - - static Future stopService() async { - try { - await _channel.invokeMethod('stopService'); - } on PlatformException catch (e) { - _logger.e("Failed to stop service: ${e.message}"); - } - } -} diff --git a/lib/features/order/screens/add_order_screen.dart b/lib/features/order/screens/add_order_screen.dart index aeb002cc..ca33d536 100644 --- a/lib/features/order/screens/add_order_screen.dart +++ b/lib/features/order/screens/add_order_screen.dart @@ -14,7 +14,7 @@ import 'package:mostro_mobile/features/order/providers/order_notifier_provider.d import 'package:mostro_mobile/shared/widgets/currency_combo_box.dart'; import 'package:mostro_mobile/shared/widgets/currency_text_field.dart'; import 'package:mostro_mobile/shared/providers/exchange_service_provider.dart'; -import 'package:mostro_mobile/shared/widgets/nostr_responsive_button.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_reactive_button.dart'; import 'package:uuid/uuid.dart'; // Create a direct state provider tied to the order action/status diff --git a/lib/features/trades/screens/trade_detail_screen.dart b/lib/features/trades/screens/trade_detail_screen.dart index 1a5f3e0a..0a9561ad 100644 --- a/lib/features/trades/screens/trade_detail_screen.dart +++ b/lib/features/trades/screens/trade_detail_screen.dart @@ -16,7 +16,7 @@ import 'package:mostro_mobile/features/trades/widgets/mostro_message_detail_widg import 'package:mostro_mobile/shared/providers/session_manager_provider.dart'; import 'package:mostro_mobile/shared/utils/currency_utils.dart'; import 'package:mostro_mobile/shared/widgets/custom_card.dart'; -import 'package:mostro_mobile/shared/widgets/nostr_responsive_button.dart'; +import 'package:mostro_mobile/shared/widgets/mostro_reactive_button.dart'; class TradeDetailScreen extends ConsumerWidget { final String orderId; @@ -493,13 +493,12 @@ class TradeDetailScreen extends ConsumerWidget { buttonStyle: ButtonStyleType.raised, orderId: orderId, action: action, + backgroundColor: backgroundColor, onPressed: onPressed, showSuccessIndicator: true, timeout: const Duration(seconds: 30), ); } - - /// CONTACT Widget _buildContactButton(BuildContext context) { return ElevatedButton( onPressed: () { diff --git a/lib/features/trades/screens/trades_screen.dart b/lib/features/trades/screens/trades_screen.dart index eb343fef..afc56b2f 100644 --- a/lib/features/trades/screens/trades_screen.dart +++ b/lib/features/trades/screens/trades_screen.dart @@ -47,9 +47,9 @@ class TradesScreen extends ConsumerWidget { ), // Use the async value pattern to handle different states tradesAsync.when( - data: (trades) => _buildFilterButton(context, trades), - loading: () => _buildFilterButton(context, []), - error: (error, _) => _buildFilterButton(context, []), + data: (trades) => _buildFilterButton(context, ref, trades), + loading: () => _buildFilterButton(context, ref, []), + error: (error, _) => _buildFilterButton(context, ref, []), ), const SizedBox(height: 6.0), Expanded( @@ -98,7 +98,7 @@ class TradesScreen extends ConsumerWidget { ); } - Widget _buildFilterButton(BuildContext context, List trades) { + Widget _buildFilterButton(BuildContext context, WidgetRef ref, List trades) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( @@ -107,24 +107,24 @@ class TradesScreen extends ConsumerWidget { OutlinedButton.icon( onPressed: () { showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (BuildContext context) { - return const Padding( - padding: EdgeInsets.all(16.0), - child: OrderFilter(), - ); - }, + context: context, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: OrderFilter(), + ); + }, ); }, icon: const HeroIcon(HeroIcons.funnel, - style: HeroIconStyle.outline, color: AppTheme.cream1), + style: HeroIconStyle.outline, color: AppTheme.cream1), label: - const Text("FILTER", style: TextStyle(color: AppTheme.cream1)), + const Text("FILTER", style: TextStyle(color: AppTheme.cream1)), style: OutlinedButton.styleFrom( side: const BorderSide(color: AppTheme.cream1), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(20), ), ), ), @@ -137,11 +137,9 @@ class TradesScreen extends ConsumerWidget { IconButton( icon: const Icon(Icons.refresh, color: AppTheme.cream1), onPressed: () { - // Get the riverpod context from the widget - final container = ProviderScope.containerOf(context, listen: false); - // Invalidate providers to force refresh - container.invalidate(orderEventsProvider); - container.invalidate(filteredTradesProvider); + // Use the ref from the build method + ref.invalidate(orderEventsProvider); + ref.invalidate(filteredTradesProvider); }, ), ], diff --git a/lib/shared/providers/mostro_storage_provider.dart b/lib/shared/providers/mostro_storage_provider.dart index 7111c208..b251e7d7 100644 --- a/lib/shared/providers/mostro_storage_provider.dart +++ b/lib/shared/providers/mostro_storage_provider.dart @@ -25,8 +25,3 @@ final mostroOrderStreamProvider = StreamProvider.family( final storage = ref.read(mostroStorageProvider); return storage.watchLatestMessageOfType(orderId); }); - -final mostroOrderProvider = StreamProvider.family((ref, orderId) { - final storage = ref.read(mostroStorageProvider); - return storage.watchLatestMessage(orderId); -}); diff --git a/lib/shared/utils/tray_manager.dart b/lib/shared/utils/tray_manager.dart index 18d70464..b7151d8b 100644 --- a/lib/shared/utils/tray_manager.dart +++ b/lib/shared/utils/tray_manager.dart @@ -1,7 +1,8 @@ import 'dart:io'; -import 'package:system_tray/system_tray.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:system_tray/system_tray.dart'; class TrayManager { static final TrayManager _instance = TrayManager._internal(); @@ -11,41 +12,59 @@ class TrayManager { TrayManager._internal(); - Future init(GlobalKey navigatorKey) async { - const iconPath = 'assets/images/launcher-icon.png'; - - await _tray.initSystemTray( - iconPath: iconPath, - toolTip: "Mostro is running", - title: '', - ); + Future init( + GlobalKey navigatorKey, { + String iconPath = 'assets/images/launcher-icon.png', + }) async { + try { + await _tray.initSystemTray( + iconPath: iconPath, + toolTip: "Mostro is running", + title: '', + ); - final menu = Menu(); + final menu = Menu(); - menu.buildFrom([ - MenuItemLabel( - label: 'Open Mostro', - onClicked: (menuItem) { - navigatorKey.currentState?.pushNamed('/'); - }, - ), - MenuItemLabel( - label: 'Quit', + menu.buildFrom([ + MenuItemLabel( + label: 'Open Mostro', onClicked: (menuItem) { - _tray.destroy(); + navigatorKey.currentState?.pushNamed('/'); + }, + ), + MenuItemLabel( + label: 'Quit', + onClicked: (menuItem) async { + await dispose(); Future.delayed(const Duration(milliseconds: 300), () { - exit(0); + if (Platform.isAndroid || Platform.isIOS) { + SystemNavigator.pop(); + } else { + exit(0); // Only as a last resort on desktop + } }); - }), - ]); + }, + ), + ]); + + await _tray.setContextMenu(menu); - await _tray.setContextMenu(menu); + // Handle tray icon click (e.g., double click = open) + _tray.registerSystemTrayEventHandler((eventName) { + if (eventName == kSystemTrayEventClick) { + navigatorKey.currentState?.pushNamed('/'); + } + }); + } catch (e) { + debugPrint('Failed to initialize system tray: $e'); + } + } - // Handle tray icon click (e.g., double click = open) - _tray.registerSystemTrayEventHandler((eventName) { - if (eventName == kSystemTrayEventClick) { - navigatorKey.currentState?.pushNamed('/'); - } - }); + Future dispose() async { + try { + await _tray.destroy(); + } catch (e) { + debugPrint('Failed to destroy system tray: $e'); + } } } diff --git a/lib/shared/widgets/nostr_responsive_button.dart b/lib/shared/widgets/mostro_reactive_button.dart similarity index 96% rename from lib/shared/widgets/nostr_responsive_button.dart rename to lib/shared/widgets/mostro_reactive_button.dart index 1b27bb8a..bc5f3227 100644 --- a/lib/shared/widgets/nostr_responsive_button.dart +++ b/lib/shared/widgets/mostro_reactive_button.dart @@ -27,12 +27,13 @@ class MostroReactiveButton extends ConsumerStatefulWidget { required this.orderId, required this.action, this.timeout = const Duration(seconds: 30), - this.showSuccessIndicator = false, + Color? backgroundColor, }); @override - ConsumerState createState() => _MostroReactiveButtonState(); + ConsumerState createState() => + _MostroReactiveButtonState(); } class _MostroReactiveButtonState extends ConsumerState { @@ -124,6 +125,5 @@ class _MostroReactiveButtonState extends ConsumerState { } return button; - } } From a88ba04d17a05af27605861b90c2d389f248f96d Mon Sep 17 00:00:00 2001 From: Biz Date: Thu, 8 May 2025 09:10:53 -0700 Subject: [PATCH 148/149] Refactor notification handling, improve order update logic, and enhance button customization --- lib/background/background.dart | 2 +- lib/data/repositories/base_storage.dart | 2 +- .../repositories/open_orders_repository.dart | 2 +- lib/features/trades/models/trade_state.dart | 27 ++++ lib/notifications/notification_service.dart | 31 ++++ .../widgets/mostro_reactive_button.dart | 28 +++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 140 ------------------ pubspec.yaml | 6 - 9 files changed, 85 insertions(+), 155 deletions(-) diff --git a/lib/background/background.dart b/lib/background/background.dart index bcb1b533..cd86c25f 100644 --- a/lib/background/background.dart +++ b/lib/background/background.dart @@ -73,7 +73,7 @@ Future serviceMain(ServiceInstance service) async { subscription.listen((event) async { if (await eventStore.hasItem(event.id!)) return; - await showLocalNotification(event); + await retryNotification(event); }); }); diff --git a/lib/data/repositories/base_storage.dart b/lib/data/repositories/base_storage.dart index 19267520..e5490494 100644 --- a/lib/data/repositories/base_storage.dart +++ b/lib/data/repositories/base_storage.dart @@ -50,7 +50,7 @@ abstract class BaseStorage { Future deleteWhere(Filter filter) async { return await db.transaction((txn) async { return await store.delete( - db, + txn, finder: Finder(filter: filter), ); }); diff --git a/lib/data/repositories/open_orders_repository.dart b/lib/data/repositories/open_orders_repository.dart index 5dca74f7..325049d0 100644 --- a/lib/data/repositories/open_orders_repository.dart +++ b/lib/data/repositories/open_orders_repository.dart @@ -114,7 +114,7 @@ class OpenOrdersRepository implements OrderRepository { @override Future updateOrder(NostrEvent order) { - if (_events.containsKey(order.id)) { + if (order.id != null && _events.containsKey(order.id)) { _events[order.id!] = order; _emitEvents(); } diff --git a/lib/features/trades/models/trade_state.dart b/lib/features/trades/models/trade_state.dart index d82b626c..bb682698 100644 --- a/lib/features/trades/models/trade_state.dart +++ b/lib/features/trades/models/trade_state.dart @@ -12,4 +12,31 @@ class TradeState { required this.lastAction, required this.orderPayload, }); + + @override + String toString() => + 'TradeState(status: $status, lastAction: $lastAction, orderPayload: $orderPayload)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TradeState && + other.status == status && + other.lastAction == lastAction && + other.orderPayload == orderPayload; + + @override + int get hashCode => Object.hash(status, lastAction, orderPayload); + + TradeState copyWith({ + Status? status, + actions.Action? lastAction, + Order? orderPayload, + }) { + return TradeState( + status: status ?? this.status, + lastAction: lastAction ?? this.lastAction, + orderPayload: orderPayload ?? this.orderPayload, + ); + } } diff --git a/lib/notifications/notification_service.dart b/lib/notifications/notification_service.dart index 80bf2941..706f005d 100644 --- a/lib/notifications/notification_service.dart +++ b/lib/notifications/notification_service.dart @@ -1,5 +1,8 @@ +import 'dart:math'; + import 'package:dart_nostr/nostr/model/event/event.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:logger/logger.dart'; final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); @@ -55,3 +58,31 @@ Future showLocalNotification(NostrEvent event) async { details, ); } +Future retryNotification(NostrEvent event, {int maxAttempts = 3}) async { + int attempt = 0; + bool success = false; + + while (!success && attempt < maxAttempts) { + try { + await showLocalNotification(event); + success = true; + } catch (e) { + attempt++; + if (attempt >= maxAttempts) { + Logger().e('Failed to show notification after $maxAttempts attempts: $e'); + break; + } + + // Exponential backoff: 1s, 2s, 4s, etc. + final backoffSeconds = pow(2, attempt - 1).toInt(); + Logger().e('Notification attempt $attempt failed: $e. Retrying in ${backoffSeconds}s'); + await Future.delayed(Duration(seconds: backoffSeconds)); + } + } + + // Optionally store failed notifications for later retry when app returns to foreground + if (!success) { + // Store the event ID in a persistent queue for later retry + // await failedNotificationsQueue.add(event.id!); + } +} diff --git a/lib/shared/widgets/mostro_reactive_button.dart b/lib/shared/widgets/mostro_reactive_button.dart index bc5f3227..b3546acd 100644 --- a/lib/shared/widgets/mostro_reactive_button.dart +++ b/lib/shared/widgets/mostro_reactive_button.dart @@ -19,6 +19,8 @@ class MostroReactiveButton extends ConsumerStatefulWidget { final bool showSuccessIndicator; + final Color? backgroundColor; + const MostroReactiveButton({ super.key, required this.label, @@ -28,7 +30,7 @@ class MostroReactiveButton extends ConsumerStatefulWidget { required this.action, this.timeout = const Duration(seconds: 30), this.showSuccessIndicator = false, - Color? backgroundColor, + this.backgroundColor, }); @override @@ -104,21 +106,39 @@ class _MostroReactiveButtonState extends ConsumerState { case ButtonStyleType.raised: button = ElevatedButton( onPressed: _loading ? null : _startOperation, - style: AppTheme.theme.elevatedButtonTheme.style, + style: widget.backgroundColor != null + ? AppTheme.theme.elevatedButtonTheme.style?.copyWith( + backgroundColor: WidgetStateProperty.resolveWith( + (_) => widget.backgroundColor!, + ), + ) + : AppTheme.theme.elevatedButtonTheme.style, child: childWidget, ); break; case ButtonStyleType.outlined: button = OutlinedButton( onPressed: _loading ? null : _startOperation, - style: AppTheme.theme.outlinedButtonTheme.style, + style: widget.backgroundColor != null + ? AppTheme.theme.elevatedButtonTheme.style?.copyWith( + backgroundColor: WidgetStateProperty.resolveWith( + (_) => widget.backgroundColor!, + ), + ) + : AppTheme.theme.elevatedButtonTheme.style, child: childWidget, ); break; case ButtonStyleType.text: button = TextButton( onPressed: _loading ? null : _startOperation, - style: AppTheme.theme.textButtonTheme.style, + style: widget.backgroundColor != null + ? AppTheme.theme.elevatedButtonTheme.style?.copyWith( + backgroundColor: WidgetStateProperty.resolveWith( + (_) => widget.backgroundColor!, + ), + ) + : AppTheme.theme.elevatedButtonTheme.style, child: childWidget, ); break; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c48f6d99..30dc8f04 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,7 +10,6 @@ import flutter_secure_storage_darwin import local_auth_darwin import path_provider_foundation import shared_preferences_foundation -import sqflite_darwin import system_tray func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -19,6 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index ae2615ae..ed220c15 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -302,14 +302,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.10" - dart_flutter_team_lints: - dependency: transitive - description: - name: dart_flutter_team_lints - sha256: "4c8f38142598339cd28c0b48a66b6b04434ee0499b6e40baf7c62c76daa1fcad" - url: "https://pub.dev" - source: hosted - version: "3.5.1" dart_nostr: dependency: "direct main" description: @@ -334,14 +326,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" - dev_build: - dependency: transitive - description: - name: dev_build - sha256: "87abaa4f6cfd50320b229e29a7ef5532d86bc804f3124328c7e9db238ba45b33" - url: "https://pub.dev" - source: hosted - version: "1.1.2+8" dots_indicator: dependency: transitive description: @@ -1138,14 +1122,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.3" - process_run: - dependency: transitive - description: - name: process_run - sha256: "6ec839cdd3e6de4685318e7686cd4abb523c3d3a55af0e8d32a12ae19bc66622" - url: "https://pub.dev" - source: hosted - version: "1.2.4" pub_semver: dependency: transitive description: @@ -1226,14 +1202,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.8.5" - sembast_sqflite: - dependency: transitive - description: - name: sembast_sqflite - sha256: "38ffa967af36d6867d087ec82c37b70b55707b2cd533157e8a32d15aa66e40d2" - url: "https://pub.dev" - source: hosted - version: "2.2.1" sembast_web: dependency: "direct main" description: @@ -1367,70 +1335,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" - sqflite: - dependency: transitive - description: - name: sqflite - sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 - url: "https://pub.dev" - source: hosted - version: "2.4.2" - sqflite_android: - dependency: transitive - description: - name: sqflite_android - sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - sqflite_common: - dependency: transitive - description: - name: sqflite_common - sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" - url: "https://pub.dev" - source: hosted - version: "2.5.5" - sqflite_common_ffi: - dependency: transitive - description: - name: sqflite_common_ffi - sha256: "1f3ef3888d3bfbb47785cc1dda0dc7dd7ebd8c1955d32a9e8e9dae1e38d1c4c1" - url: "https://pub.dev" - source: hosted - version: "2.3.5" - sqflite_common_ffi_web: - dependency: transitive - description: - name: sqflite_common_ffi_web - sha256: cfc9d1c61a3e06e5b2e96994a44b11125b4f451fee95b9fad8bd473b4613d592 - url: "https://pub.dev" - source: hosted - version: "0.4.3+1" - sqflite_darwin: - dependency: transitive - description: - name: sqflite_darwin - sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - sqflite_platform_interface: - dependency: transitive - description: - name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - sqlite3: - dependency: transitive - description: - name: sqlite3 - sha256: fde692580bee3379374af1f624eb3e113ab2865ecb161dbe2d8ac2de9735dbdb - url: "https://pub.dev" - source: hosted - version: "2.4.5" stack_trace: dependency: transitive description: @@ -1495,42 +1399,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" - tekartik_app_flutter_sembast: - dependency: "direct main" - description: - path: app_sembast - ref: dart3a - resolved-ref: "7c0aac5f8ec557a4fe975798600c47e4dcce0538" - url: "https://github.com/tekartik/app_flutter_utils.dart" - source: git - version: "0.5.1" - tekartik_app_flutter_sqflite: - dependency: transitive - description: - path: app_sqflite - ref: dart3a - resolved-ref: "7c0aac5f8ec557a4fe975798600c47e4dcce0538" - url: "https://github.com/tekartik/app_flutter_utils.dart" - source: git - version: "0.6.1" - tekartik_lints: - dependency: transitive - description: - path: "packages/lints" - ref: dart3a - resolved-ref: "6f9dded79246357567633739554ae58bde1bc9a3" - url: "https://github.com/tekartik/common.dart" - source: git - version: "0.4.2" - tekartik_lints_flutter: - dependency: transitive - description: - path: "packages/lints_flutter" - ref: dart3a - resolved-ref: "4d58d3cc559c5c92854fce73fc176c5951e78338" - url: "https://github.com/tekartik/common_flutter.dart" - source: git - version: "0.2.3" term_glyph: dependency: transitive description: @@ -1635,14 +1503,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - very_good_analysis: - dependency: transitive - description: - name: very_good_analysis - sha256: "62d2b86d183fb81b2edc22913d9f155d26eb5cf3855173adb1f59fac85035c63" - url: "https://pub.dev" - source: hosted - version: "7.0.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 78c9e7c3..6c40fce2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,12 +73,6 @@ dependencies: url: https://github.com/chebizarro/dart-nip44.git ref: master - tekartik_app_flutter_sembast: - git: - url: https://github.com/tekartik/app_flutter_utils.dart - ref: dart3a - path: app_sembast - version: '>=0.1.0' flutter_local_notifications: ^19.0.0 flutter_background_service: ^5.1.0 system_tray: ^2.0.3 From cbcd51bf8eaef276935cb6389b9146efd74297fe Mon Sep 17 00:00:00 2001 From: Biz Date: Thu, 8 May 2025 09:41:25 -0700 Subject: [PATCH 149/149] Refactor notification service to use a single instance of FlutterLocalNotificationsPlugin and update MostroReactiveButton styles to use outlined and text button themes; remove unused rxdart dependency --- lib/notifications/notification_service.dart | 9 ++++----- lib/shared/widgets/mostro_reactive_button.dart | 8 ++++---- pubspec.lock | 8 -------- pubspec.yaml | 1 - 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/lib/notifications/notification_service.dart b/lib/notifications/notification_service.dart index 706f005d..a133838c 100644 --- a/lib/notifications/notification_service.dart +++ b/lib/notifications/notification_service.dart @@ -5,7 +5,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:logger/logger.dart'; final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin(); + FlutterLocalNotificationsPlugin(); Future initializeNotifications() async { const android = AndroidInitializationSettings( @@ -23,12 +23,10 @@ Future initializeNotifications() async { linux: linux, macOS: ios ); - final plugin = FlutterLocalNotificationsPlugin(); - await plugin.initialize(initSettings); + await flutterLocalNotificationsPlugin.initialize(initSettings); } Future showLocalNotification(NostrEvent event) async { - final notificationsPlugin = FlutterLocalNotificationsPlugin(); const details = NotificationDetails( android: AndroidNotificationDetails( 'mostro_channel', @@ -51,13 +49,14 @@ Future showLocalNotification(NostrEvent event) async { interruptionLevel: InterruptionLevel.critical, ), ); - await notificationsPlugin.show( + await flutterLocalNotificationsPlugin.show( event.id.hashCode, // Use unique ID for each event 'New Mostro Event', 'You have received a new message from Mostro', details, ); } + Future retryNotification(NostrEvent event, {int maxAttempts = 3}) async { int attempt = 0; bool success = false; diff --git a/lib/shared/widgets/mostro_reactive_button.dart b/lib/shared/widgets/mostro_reactive_button.dart index b3546acd..5dd7a76d 100644 --- a/lib/shared/widgets/mostro_reactive_button.dart +++ b/lib/shared/widgets/mostro_reactive_button.dart @@ -120,12 +120,12 @@ class _MostroReactiveButtonState extends ConsumerState { button = OutlinedButton( onPressed: _loading ? null : _startOperation, style: widget.backgroundColor != null - ? AppTheme.theme.elevatedButtonTheme.style?.copyWith( + ? AppTheme.theme.outlinedButtonTheme.style?.copyWith( backgroundColor: WidgetStateProperty.resolveWith( (_) => widget.backgroundColor!, ), ) - : AppTheme.theme.elevatedButtonTheme.style, + : AppTheme.theme.outlinedButtonTheme.style, child: childWidget, ); break; @@ -133,12 +133,12 @@ class _MostroReactiveButtonState extends ConsumerState { button = TextButton( onPressed: _loading ? null : _startOperation, style: widget.backgroundColor != null - ? AppTheme.theme.elevatedButtonTheme.style?.copyWith( + ? AppTheme.theme.textButtonTheme.style?.copyWith( backgroundColor: WidgetStateProperty.resolveWith( (_) => widget.backgroundColor!, ), ) - : AppTheme.theme.elevatedButtonTheme.style, + : AppTheme.theme.textButtonTheme.style, child: childWidget, ); break; diff --git a/pubspec.lock b/pubspec.lock index ed220c15..22061f77 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1186,14 +1186,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" - rxdart: - dependency: "direct main" - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" sembast: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 6c40fce2..9b0cae53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -77,7 +77,6 @@ dependencies: flutter_background_service: ^5.1.0 system_tray: ^2.0.3 path_provider: ^2.1.5 - rxdart: ^0.28.0 permission_handler: ^12.0.0+1 dev_dependencies: