diff --git a/docs/COMMUNITY_DISCOVERY.md b/docs/COMMUNITY_DISCOVERY.md new file mode 100644 index 00000000..babd046a --- /dev/null +++ b/docs/COMMUNITY_DISCOVERY.md @@ -0,0 +1,348 @@ +# Community Discovery: Node Selector on First Launch + +**Issue:** [#563](https://github.com/MostroP2P/mobile/issues/563) +**Reference:** [mostro.community](https://github.com/MostroP2P/community) + +## Overview + +Community Discovery allows new users to choose a trusted Mostro community/node on their first launch, before reaching the home screen. The feature mirrors the [mostro.community](https://mostro.community) website pattern: trusted node pubkeys are hardcoded, and community metadata (name, avatar, description, currencies, fee) is fetched from Nostr kind 0 and kind 38385 events. + +Existing users are never interrupted. The selector is shown only once after the walkthrough. + +## User Flow + +### New User + +```text +App install -> Walkthrough (complete or skip) -> Community Selector -> Home +``` + +### Existing User (upgrade) + +```text +App launch -> Home (auto-migrated, no interruption) +``` + +### Returning to Community Selection + +```text +Home -> Settings -> Mostro Card -> Node Selector (existing feature) +``` + +## Trusted Communities + +Mirrored from [mostro.community](https://github.com/MostroP2P/community). Defined in `lib/core/config/communities.dart`: + +| Region | Pubkey (truncated) | Social | +|--------|-------------------|--------| +| Cuba | `00000235a3e9...1366a` | [Telegram](https://t.me/Cuba_Bitcoin), [Website](https://cubabitcoin.org/kmbalache/) | +| Spain | `0000cc02101e...36b40` | [Telegram](https://t.me/nostromostro) | +| Colombia | `00000978acc5...8441b` | [Telegram](https://t.me/ColombiaP2P), [X](https://x.com/ColombiaP2P) | +| Bolivia | `00007cb3305f...3f91` | [Telegram](https://t.me/btcxbolivia), [X](https://x.com/btcxbolivia), [Instagram](https://www.instagram.com/btcxbolivia) | +| Default | `82fa8cb978b4...8390` | (fallback when user skips) | + +**Single source of truth:** `Config.trustedMostroNodes` is derived from `trustedCommunities` at runtime, eliminating duplication between the node system and community config. + +## Architecture + +### Data Flow + +```text +trustedCommunities (static config) + | + v +CommunityRepository.fetchCommunityMetadata(pubkeys) + | WebSocket -> Config.nostrRelays (with fallback) + | REQ kind 0 (profile: name, about, picture) + | REQ kind 38385 (trade info: currencies, fee, min/max) + | Timeout: 10s, partial data OK + v +communityListProvider (FutureProvider) + | Merges static config + fetched metadata + v +CommunitySelectorScreen + | User selects community -> selectNode() + markCommunitySelected() + v +Home Screen +``` + +### Layer Responsibilities + +| Layer | Component | Responsibility | +|-------|-----------|---------------| +| Config | `communities.dart` | `CommunityConfig`, `SocialLink`, `trustedCommunities`, `defaultMostroPubkey` | +| Data | `community_repository.dart` | `CommunityRepository` (WebSocket fetch), `CommunityMetadata` (parsed event data) | +| Domain | `community.dart` | `Community` model combining static config + dynamic metadata | +| State | `community_selector_provider.dart` | `communitySelectedProvider` (persistence), `communityRepositoryProvider` (DI), `communityListProvider` (fetch + enrich) | +| UI | `community_selector_screen.dart` | Full-screen selector with search, loading skeleton, error state | +| UI | `community_card.dart` | Card widget: avatar, name, region, about, currencies, fee/range, social links | + +### File Structure + +```text +lib/ + core/ + config/ + communities.dart # Trusted pubkeys, SocialLink, CommunityConfig + config.dart # Config.trustedMostroNodes (derived) + data/ + models/enums/ + storage_keys.dart # + communitySelected key + repositories/ + community_repository.dart # WebSocket fetcher + CommunityMetadata + features/ + community/ + community.dart # Community model + providers/ + community_selector_provider.dart # Riverpod providers + screens/ + community_selector_screen.dart # Main screen + widgets/ + community_card.dart # Card widget + walkthrough/ + screens/ + walkthrough_screen.dart # Modified: navigates to /community_selector +``` + +## Nostr Event Fetching + +### CommunityRepository + +Located at `lib/data/repositories/community_repository.dart`. Uses a standalone `dart:io` WebSocket connection, independent of the app's `NostrService`, so it works before full app initialization. + +**Connection:** `wss://relay.mostro.network` +**Timeout:** 10 seconds +**Error handling:** Errors propagate to `communityListProvider` which shows the error state with retry. + +### Subscriptions + +Two concurrent REQ messages on a single WebSocket: + +```json +// Kind 0: Nostr profile metadata +["REQ", "", {"kinds": [0], "authors": ["", "", ...]}] + +// Kind 38385: Mostro trade information +["REQ", "", {"kinds": [38385], "authors": ["", ...], "#y": ["mostro"]}] +``` + +Waits for both EOSE responses (or timeout), then closes subscriptions and WebSocket. + +### Event Deduplication + +For both kinds, keeps only the event with the highest `created_at` per pubkey. This handles multiple relays returning the same event. + +### Kind 0 Fields Extracted + +| Field | JSON key | Usage | +|-------|----------|-------| +| Name | `name` | Display name (fallback: region from config) | +| About | `about` | Description text on card | +| Picture | `picture` | Avatar (HTTPS only, NymAvatar fallback) | + +### Kind 38385 Tags Extracted + +| Tag | Usage | +|-----|-------| +| `fiat_currencies_accepted` | Comma-separated currency codes displayed as tags | +| `fee` | Trading fee percentage | +| `min_order_amount` | Minimum order in sats | +| `max_order_amount` | Maximum order in sats | + +### "All Currencies" Logic + +When kind 38385 event exists (`hasTradeInfo = true`) but `fiat_currencies_accepted` is empty or absent, the card displays a localized "All currencies" tag. This mirrors the behavior of [mostro.community](https://mostro.community). + +## Navigation Integration + +### GoRouter Redirect Chain + +In `lib/core/app_routes.dart`, the redirect logic evaluates two providers sequentially: + +```text +1. firstRunProvider: + - loading -> redirect to /walkthrough + - data(isFirstRun=true) -> redirect to /walkthrough + - data(isFirstRun=false) -> proceed to step 2 + +2. communitySelectedProvider: + - loading -> no redirect (wait for provider to resolve; router refreshes on change) + - data(false) -> redirect to /community_selector + - data(true) -> no redirect (proceed to requested route) + - error -> no redirect (don't block on errors) +``` + +### Route Definition + +```dart +GoRoute( + path: '/community_selector', + pageBuilder: (context, state) => buildPageWithDefaultTransition( + context: context, + state: state, + child: const CommunitySelectorScreen(), + ), +), +``` + +### Walkthrough Integration + +`WalkthroughScreen._onIntroEnd()` navigates to `/community_selector` instead of `/`. The GoRouter redirect handles the rest. + +## Backward Compatibility + +### Existing Users Auto-Migration + +`CommunitySelectedNotifier._init()` checks: + +1. If `communitySelected` is already `true` in SharedPreferences -> done. +2. If `firstRunComplete` is `true` (existing user who completed onboarding before this feature) -> auto-sets `communitySelected = true` and skips the selector. +3. Otherwise -> `false` (new user, show selector). + +This ensures users who upgrade from a version without community discovery are never interrupted. + +### Config.trustedMostroNodes + +Previously hardcoded as a single entry (`Mostro P2P`). Now derived from `trustedCommunities`, which contains all 5 community entries. The `MostroNodesNotifier` initializes from this list, so all communities appear as trusted nodes in the existing node selector (Settings -> Mostro). + +## State Management + +### communitySelectedProvider + +`StateNotifierProvider>` + +- **Storage:** `SharedPreferencesKeys.communitySelected` (`'community_selected'`) +- **Loading:** Reads SharedPreferences asynchronously +- **Mounted checks:** All async `state =` assignments guarded by `if (!mounted) return` +- **Methods:** `markCommunitySelected()` — persists selection and updates state + +### communityListProvider + +`FutureProvider>` + +1. Creates `Community.fromConfig()` for each `trustedCommunities` entry +2. Calls `CommunityRepository.fetchCommunityMetadata()` with all pubkeys +3. Enriches communities with fetched metadata via `copyWith()` +4. Returns enriched list (partial data is fine — missing metadata fields stay null) + +### communityRepositoryProvider + +`Provider` — simple factory for dependency injection. + +## Community Selector Screen + +### UI Layout + +```text ++-----------------------------+ +| bolt Choose your community | <- Title with bolt icon +| [search icon] Search... | <- Search bar (filters by name, region, currency, about) +| | +| +-------------------------+ | +| | Avatar Name check | | <- CommunityCard (selected state) +| | Region | | +| | Description text... | | +| | [USD] [EUR] [CUP] | | <- Currency tags (or "All currencies") +| | % Fee 1.0% | Range ... | | <- Fee and sats range +| | tg x ig | | <- Social link icons +| +-------------------------+ | +| [more cards...] | +| | +| gear Use a custom node | <- Opens AddCustomNodeDialog +| [========= Done =========] | <- Confirm button (visible after selection) +| Skip for now | <- Uses defaultMostroPubkey ++-----------------------------+ +``` + +### States + +| State | Behavior | +|-------|----------| +| **Loading** | Skeleton placeholders (same count as `trustedCommunities`) | +| **Error** | Cloud-off icon + error message + retry button (invalidates `communityListProvider`) | +| **Data (empty search)** | "No communities found" centered text | +| **Data** | Scrollable list of `CommunityCard` widgets | +| **Selecting** | Loading spinner on confirm button, all interactions disabled | + +### User Actions + +| Action | Behavior | +|--------|----------| +| **Tap card** | Sets `_selectedPubkey`, shows confirm button | +| **Confirm** | `_selectAndProceed()`: ensures node exists, selects it, marks community selected, navigates to `/` | +| **Skip** | Same as confirm but uses `defaultMostroPubkey` | +| **Use custom node** | Opens `AddCustomNodeDialog`; if a node was added (detected via set-diff on pubkeys), auto-selects it and proceeds | + +### _selectAndProceed() Flow + +```dart +1. _ensureNodeExists(pubkey) // Adds as custom node if not already known (awaited) +2. nodesNotifier.selectNode() // Calls settingsNotifier.updateMostroInstance() +3. markCommunitySelected() // Persists to SharedPreferences +4. context.go('/') // Navigate to home (if still mounted) +``` + +## CommunityCard Widget + +`StatelessWidget` displaying a single community entry. + +### Sections (conditional) + +1. **Header:** Avatar (network image with NymAvatar fallback + loading placeholder) + display name + region + check icon (if selected) +2. **About:** Description text, max 3 lines with ellipsis +3. **Currencies:** Green tags showing accepted fiat codes, or "All currencies" if `hasTradeInfo && currencies.isEmpty` +4. **Stats:** Fee percentage + sats range (formatted as K/M) +5. **Social:** Tappable icons for Telegram, X, Instagram, etc. + +### Visual Design + +- Uses `AppTheme` constants consistently (backgroundCard, activeColor, textPrimary, textSecondary) +- `AnimatedContainer` with 200ms transition for selection state +- Selected state: green border (alpha 0.5) + green background tint (alpha 0.1) + check icon +- Currency tags: green background (alpha 0.15) + green text + +## Internationalization + +All user-facing strings use `S.of(context)!.keyName`. Keys added across all 5 locales: + +| Key | EN | ES | IT | DE | FR | +|-----|----|----|----|----|-----| +| `chooseYourCommunity` | Choose your community | Elige tu comunidad | Scegli la tua comunita | Wahle deine Community | Choisissez votre communaute | +| `communitySearchHint` | Search communities... | Buscar comunidades... | Cerca comunita... | Communities suchen... | Rechercher des communautes... | +| `communityFee` | Fee | Comision | Commissione | Gebuhr | Frais | +| `communityRange` | Range | Rango | Intervallo | Bereich | Plage | +| `useCustomNode` | Use a custom node | Usar un nodo personalizado | Usa un nodo personalizzato | Eigenen Knoten verwenden | Utiliser un noeud personnalise | +| `communityLoadingError` | Could not load community data | No se pudieron cargar... | Impossibile caricare... | ...konnten nicht geladen werden | Impossible de charger... | +| `communityRetry` | Retry | Reintentar | Riprova | Erneut versuchen | Reessayer | +| `noCommunityResults` | No communities found | No se encontraron... | Nessuna comunita trovata | Keine Communities gefunden | Aucune communaute trouvee | +| `communityFormatSats` | {amount} sats | {amount} sats | {amount} sats | {amount} sats | {amount} sats | +| `communityAllCurrencies` | All currencies | Todas las monedas | Tutte le valute | Alle Wahrungen | Toutes les devises | + +The existing `skipForNow` and `done` keys are reused from prior translations. + +## Ancillary Changes + +### Dependency Conflict Resolution + +Removed `riverpod_generator` and `riverpod_annotation` dev dependencies that conflicted with Flutter 3.41.6's `analyzer` requirement. The 3 files using `@riverpod` annotations were converted to manual providers matching the rest of the codebase: + +| File | Change | +|------|--------| +| `lib/services/event_bus.dart` | `@riverpod` -> `AutoDisposeProvider` | +| `lib/shared/providers/mostro_service_provider.dart` | `@Riverpod(keepAlive: true)` -> `Provider` | +| `lib/features/order/providers/order_notifier_provider.dart` | `@riverpod class OrderTypeNotifier` -> `StateNotifier` + `AutoDisposeStateNotifierProvider` | + +### google_fonts Upgrade + +Upgraded `google_fonts` from 6.2.1 to 6.3.3 (within existing `^6.2.1` constraint) to fix `FontWeight` constant evaluation error on Flutter 3.41.6. + +### MostroService.init() Idempotency + +Added `_ordersSubscription?.cancel()` at the start of `MostroService.init()` to prevent subscription leaks when called from `LifecycleManager.onResumed()`. + +## Out of Scope (v1) + +- Decentralized community discovery via NIP +- Real-time updates when community changes their kind 38385 +- User-created communities (curated list only) +- Settings screen community section (uses existing Mostro node selector) diff --git a/lib/core/app.dart b/lib/core/app.dart index ef1e6ddf..9f6a5747 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -16,6 +16,7 @@ import 'package:mostro_mobile/features/notifications/services/background_notific import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; import 'package:mostro_mobile/features/settings/settings_provider.dart'; import 'package:mostro_mobile/shared/notifiers/locale_notifier.dart'; +import 'package:mostro_mobile/features/community/providers/community_selector_provider.dart'; import 'package:mostro_mobile/features/walkthrough/providers/first_run_provider.dart'; import 'package:mostro_mobile/features/restore/restore_overlay.dart'; import 'package:mostro_mobile/shared/widgets/nwc_notification_listener.dart'; @@ -122,8 +123,12 @@ class _MostroAppState extends ConsumerState { return initAsyncValue.when( data: (_) { - // Initialize first run provider + // Watch providers that affect routing ref.watch(firstRunProvider); + // Refresh router when community selection state resolves + ref.listen(communitySelectedProvider, (_, __) { + _router?.refresh(); + }); ref.listen(authNotifierProvider, (previous, state) { WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index a7cf50e4..3bb15667 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -29,6 +29,8 @@ import 'package:mostro_mobile/features/logs/screens/logs_screen.dart'; import 'package:mostro_mobile/features/logs/widgets/logs_recording_indicator.dart'; import 'package:mostro_mobile/core/app.dart'; +import 'package:mostro_mobile/features/community/providers/community_selector_provider.dart'; +import 'package:mostro_mobile/features/community/screens/community_selector_screen.dart'; import 'package:mostro_mobile/features/walkthrough/providers/first_run_provider.dart'; import 'package:mostro_mobile/shared/widgets/navigation_listener_widget.dart'; import 'package:mostro_mobile/shared/widgets/notification_listener_widget.dart'; @@ -53,6 +55,20 @@ GoRouter createRouter(WidgetRef ref) { if (isFirstRun && state.matchedLocation != '/walkthrough') { return '/walkthrough'; } + + // After walkthrough, check if community was selected + if (!isFirstRun && + state.matchedLocation != '/community_selector' && + state.matchedLocation != '/walkthrough') { + final communityState = ref.read(communitySelectedProvider); + final redirect = communityState.when( + data: (selected) => selected ? null : '/community_selector', + loading: () => null, // Wait for provider to resolve + error: (_, __) => null, + ); + if (redirect != null) return redirect; + } + return null; }, loading: () { @@ -204,6 +220,15 @@ GoRouter createRouter(WidgetRef ref) { child: WalkthroughScreen(), ), ), + GoRoute( + path: '/community_selector', + pageBuilder: (context, state) => + buildPageWithDefaultTransition( + context: context, + state: state, + child: const CommunitySelectorScreen(), + ), + ), GoRoute( path: '/add_order', pageBuilder: (context, state) => diff --git a/lib/core/app_theme.dart b/lib/core/app_theme.dart index 810a384a..62ad8471 100644 --- a/lib/core/app_theme.dart +++ b/lib/core/app_theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; class AppTheme { @@ -95,6 +96,7 @@ class AppTheme { appBarTheme: const AppBarTheme( backgroundColor: Colors.transparent, elevation: 0, + systemOverlayStyle: SystemUiOverlayStyle.light, ), dialogTheme: DialogThemeData( backgroundColor: dark2, diff --git a/lib/core/config.dart b/lib/core/config.dart index 211e1641..b3b52e1d 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:mostro_mobile/core/config/communities.dart'; class Config { // Nostr configuration @@ -9,21 +10,19 @@ class Config { //'ws://10.0.2.2:7000', // mobile emulator ]; - // Trusted Mostro nodes registry - static const String _defaultMostroPubKey = - '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; - - static const List> trustedMostroNodes = [ - { - 'pubkey': _defaultMostroPubKey, - 'name': 'Mostro P2P', - }, - ]; + // Derived from trustedCommunities to maintain single source of truth + static final List> trustedMostroNodes = + trustedCommunities + .map((c) => { + 'pubkey': c.pubkey, + 'name': c.region, + }) + .toList(); // Mostro hexkey (backward compatible, overridable via env variable) static const String mostroPubKey = String.fromEnvironment( 'MOSTRO_PUB_KEY', - defaultValue: _defaultMostroPubKey, + defaultValue: defaultMostroPubkey, ); static const String dBName = 'mostro.db'; diff --git a/lib/core/config/communities.dart b/lib/core/config/communities.dart new file mode 100644 index 00000000..e41b60e2 --- /dev/null +++ b/lib/core/config/communities.dart @@ -0,0 +1,71 @@ +/// Social link for a community (Telegram, X, Instagram, etc.) +class SocialLink { + final String type; + final String url; + + const SocialLink({required this.type, required this.url}); +} + +/// Static configuration for a trusted Mostro community. +/// Metadata (name, about, picture) is fetched from Nostr kind 0 events. +class CommunityConfig { + final String pubkey; + final String region; + final List social; + final String? website; + + const CommunityConfig({ + required this.pubkey, + required this.region, + this.social = const [], + this.website, + }); +} + +/// Default Mostro node pubkey (used when user skips community selection). +const String defaultMostroPubkey = + '82fa8cb978b43c79b2156585bac2c011176a21d2aead6d9f7c575c005be88390'; + +/// Trusted Mostro communities mirrored from mostro.community. +const List trustedCommunities = [ + CommunityConfig( + pubkey: + '00000235a3e904cfe1213a8a54d6f1ec1bef7cc6bfaabd6193e82931ccf1366a', + region: '\u{1F1E8}\u{1F1FA} Cuba', + social: [SocialLink(type: 'telegram', url: 'https://t.me/Cuba_Bitcoin')], + website: 'https://cubabitcoin.org/kmbalache/', + ), + CommunityConfig( + pubkey: + '0000cc02101ec29eea9ce623258752b9d7da66c27845ed26846dd0b0fc736b40', + region: '\u{1F1EA}\u{1F1F8} Espa\u{00F1}a', + social: [SocialLink(type: 'telegram', url: 'https://t.me/nostromostro')], + ), + CommunityConfig( + pubkey: + '00000978acc594c506976c655b6decbf2d4af25ffdaa6680f2a9568b0a88441b', + region: '\u{1F1E8}\u{1F1F4} Colombia', + social: [ + SocialLink(type: 'telegram', url: 'https://t.me/ColombiaP2P'), + SocialLink(type: 'x', url: 'https://x.com/ColombiaP2P'), + ], + ), + CommunityConfig( + pubkey: + '00007cb3305fb972f5cc83f83a8fbca1e64e93c9d1369880a9fd62ef95d23f91', + region: '\u{1F1E7}\u{1F1F4} Bolivia', + social: [ + SocialLink(type: 'telegram', url: 'https://t.me/btcxbolivia'), + SocialLink(type: 'x', url: 'https://x.com/btcxbolivia'), + SocialLink( + type: 'instagram', + url: 'https://www.instagram.com/btcxbolivia', + ), + ], + ), + CommunityConfig( + pubkey: defaultMostroPubkey, + region: '\u{1F310} Default', + social: [], + ), +]; diff --git a/lib/data/models/enums/storage_keys.dart b/lib/data/models/enums/storage_keys.dart index 2b8f8545..f2980c0b 100644 --- a/lib/data/models/enums/storage_keys.dart +++ b/lib/data/models/enums/storage_keys.dart @@ -5,7 +5,8 @@ enum SharedPreferencesKeys { firstRunComplete('first_run_complete'), mostroCustomNodes('mostro_custom_nodes'), trustedNodeMetadata('trusted_node_metadata'), - backgroundFilters('background_filters'); + backgroundFilters('background_filters'), + communitySelected('community_selected'); final String value; diff --git a/lib/data/repositories/community_repository.dart b/lib/data/repositories/community_repository.dart new file mode 100644 index 00000000..ac17721e --- /dev/null +++ b/lib/data/repositories/community_repository.dart @@ -0,0 +1,309 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'package:crypto/crypto.dart'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:mostro_mobile/core/config.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; + +/// Repository that fetches community metadata from Nostr relays. +/// Uses a standalone WebSocket connection to fetch kind 0 (profile) and +/// kind 38385 (Mostro info) events without depending on NostrService. +class CommunityRepository { + static const Duration _timeout = Duration(seconds: 10); + + /// Fetches kind 0 and kind 38385 events for the given pubkeys. + /// Tries each relay from [Config.nostrRelays] until one succeeds. + /// Throws if all relays fail. + Future> fetchCommunityMetadata( + List pubkeys, + ) async { + final relays = Config.nostrRelays; + Object? lastError; + + for (final relayUrl in relays) { + try { + return await _fetchFromRelay(relayUrl, pubkeys); + } catch (e) { + logger.w('Community fetch failed on $relayUrl: $e'); + lastError = e; + } + } + + throw lastError ?? Exception('No relays configured'); + } + + Future> _fetchFromRelay( + String relayUrl, + List pubkeys, + ) async { + final results = {}; + for (final pk in pubkeys) { + results[pk] = CommunityMetadata(); + } + + WebSocket? ws; + try { + ws = await WebSocket.connect(relayUrl).timeout(_timeout); + + final subIdKind0 = _randomSubId(); + final subIdKind38385 = _randomSubId(); + + // Send REQ for kind 0 + ws.add(jsonEncode([ + 'REQ', + subIdKind0, + { + 'kinds': [0], + 'authors': pubkeys, + }, + ])); + + // Send REQ for kind 38385 + ws.add(jsonEncode([ + 'REQ', + subIdKind38385, + { + 'kinds': [38385], + 'authors': pubkeys, + '#y': ['mostro'], + }, + ])); + + var eoseCount = 0; + var hasEvents = false; + final completer = Completer(); + + final subscription = ws.listen( + (data) { + try { + final msg = jsonDecode(data as String) as List; + if (msg.isEmpty) return; + + final type = msg[0] as String; + + if (type == 'EVENT' && msg.length >= 3) { + final event = msg[2] as Map; + if (_processEvent(event, results)) { + hasEvents = true; + } + } else if (type == 'EOSE') { + eoseCount++; + if (eoseCount >= 2 && !completer.isCompleted) { + completer.complete(); + } + } + } catch (e) { + logger.w('Error parsing relay message: $e'); + } + }, + onError: (e) { + logger.e('WebSocket error: $e'); + if (!completer.isCompleted) { + completer.completeError(e); + } + }, + onDone: () { + if (!completer.isCompleted) { + if (!hasEvents && eoseCount < 2) { + completer.completeError( + Exception('Connection closed without verified events'), + ); + } else { + completer.complete(); + } + } + }, + ); + + try { + await completer.future.timeout(_timeout); + } on TimeoutException { + if (!hasEvents) rethrow; + logger.w( + 'Timeout on $relayUrl ($eoseCount/2 EOSEs), ' + 'returning partial results', + ); + } + + // Close subscriptions + try { + ws.add(jsonEncode(['CLOSE', subIdKind0])); + ws.add(jsonEncode(['CLOSE', subIdKind38385])); + } catch (_) {} + await subscription.cancel(); + } catch (e) { + logger.e('Failed to fetch community data from $relayUrl: $e'); + rethrow; + } finally { + try { + await ws?.close(); + } catch (_) {} + } + + return results; + } + + /// Verifies a raw Nostr event per NIP-01: + /// 1. Recomputes the event ID from the serialized content + /// 2. Verifies the Schnorr signature over the ID + @visibleForTesting + bool verifyEvent(Map event) { + try { + final id = event['id'] as String?; + final pubkey = event['pubkey'] as String?; + final sig = event['sig'] as String?; + final createdAt = event['created_at'] as int?; + final kind = event['kind'] as int?; + final content = event['content'] as String? ?? ''; + final tags = event['tags'] as List?; + + if (id == null || + pubkey == null || + sig == null || + createdAt == null || + kind == null) { + return false; + } + + // Verify event ID matches content hash + final serialized = + jsonEncode([0, pubkey, createdAt, kind, tags ?? [], content]); + final computedId = + sha256.convert(utf8.encode(serialized)).toString(); + + if (computedId != id) { + logger.w( + 'Event ID mismatch for $pubkey: expected $computedId, got $id', + ); + return false; + } + + // Verify Schnorr signature + if (!NostrKeyPairs.verify(pubkey, id, sig)) { + logger.w('Signature verification failed for kind $kind from $pubkey'); + return false; + } + + return true; + } catch (e) { + logger.w('Signature verification error: $e'); + return false; + } + } + + /// Processes a raw event, applying it to [results] if it passes + /// verification. Returns true if the event was verified and applied. + bool _processEvent( + Map event, + Map results, + ) { + final pubkey = event['pubkey'] as String?; + final kind = event['kind'] as int?; + if (pubkey == null || kind == null) return false; + + final meta = results[pubkey]; + if (meta == null) return false; + + if (!verifyEvent(event)) { + logger.w( + 'Rejecting kind $kind event from $pubkey: verification failed', + ); + return false; + } + + final createdAt = event['created_at'] as int? ?? 0; + + if (kind == 0) { + if (createdAt >= (meta.kind0CreatedAt ?? 0)) { + try { + final content = + jsonDecode(event['content'] as String) as Map; + meta.kind0 = content; + meta.kind0CreatedAt = createdAt; + return true; + } catch (e) { + logger.w('Failed to parse kind 0 content for $pubkey: $e'); + return false; + } + } + } else if (kind == 38385) { + if (createdAt >= (meta.kind38385CreatedAt ?? 0)) { + meta.kind38385Tags = _extractTags(event); + meta.kind38385CreatedAt = createdAt; + return true; + } + } + + return false; + } + + Map _extractTags(Map event) { + final tags = event['tags'] as List? ?? []; + final result = {}; + for (final tag in tags) { + final tagList = tag as List; + if (tagList.length >= 2) { + result[tagList[0] as String] = tagList[1] as String; + } + } + return result; + } + + String _randomSubId() { + final random = Random(); + return 'community_${random.nextInt(999999).toString().padLeft(6, '0')}'; + } +} + +/// Holds fetched metadata for a single community. +class CommunityMetadata { + Map? kind0; + int? kind0CreatedAt; + Map? kind38385Tags; + int? kind38385CreatedAt; + + CommunityMetadata(); + + bool get hasTradeInfo => kind38385Tags != null; + + String? get name => kind0?['name'] as String?; + String? get about => kind0?['about'] as String?; + String? get picture { + final url = kind0?['picture'] as String?; + if (url == null || url.isEmpty) return null; + final uri = Uri.tryParse(url); + if (uri == null || uri.scheme != 'https') return null; + return url; + } + + List get currencies { + final raw = kind38385Tags?['fiat_currencies_accepted']; + if (raw == null || raw.isEmpty) return []; + return raw + .split(',') + .map((c) => c.trim()) + .where((c) => c.isNotEmpty) + .toList(); + } + + int? get minAmount { + final raw = kind38385Tags?['min_order_amount']; + if (raw == null) return null; + return int.tryParse(raw); + } + + int? get maxAmount { + final raw = kind38385Tags?['max_order_amount']; + if (raw == null) return null; + return int.tryParse(raw); + } + + double? get fee { + final raw = kind38385Tags?['fee']; + if (raw == null) return null; + return double.tryParse(raw); + } +} diff --git a/lib/features/community/community.dart b/lib/features/community/community.dart new file mode 100644 index 00000000..0373fd06 --- /dev/null +++ b/lib/features/community/community.dart @@ -0,0 +1,76 @@ +import 'package:mostro_mobile/core/config/communities.dart'; + +/// A community with its static config and dynamic metadata from Nostr events. +class Community { + final String pubkey; + final String region; + final List social; + final String? website; + + // From kind 0 (Nostr profile) + final String? name; + final String? about; + final String? picture; + + // From kind 38385 (Mostro info) + final bool hasTradeInfo; + final List currencies; + final int? minAmount; + final int? maxAmount; + final double? fee; + + const Community({ + required this.pubkey, + required this.region, + this.social = const [], + this.website, + this.name, + this.about, + this.picture, + this.hasTradeInfo = false, + this.currencies = const [], + this.minAmount, + this.maxAmount, + this.fee, + }); + + /// Create from static config (before Nostr data is fetched). + factory Community.fromConfig(CommunityConfig config) { + return Community( + pubkey: config.pubkey, + region: config.region, + social: config.social, + website: config.website, + ); + } + + /// Display name: kind 0 name, or region as fallback. + String get displayName => name ?? region; + + /// Copy with updated metadata fields. + Community copyWith({ + String? name, + String? about, + String? picture, + bool? hasTradeInfo, + List? currencies, + int? minAmount, + int? maxAmount, + double? fee, + }) { + return Community( + pubkey: pubkey, + region: region, + social: social, + website: website, + name: name ?? this.name, + about: about ?? this.about, + picture: picture ?? this.picture, + hasTradeInfo: hasTradeInfo ?? this.hasTradeInfo, + currencies: currencies ?? this.currencies, + minAmount: minAmount ?? this.minAmount, + maxAmount: maxAmount ?? this.maxAmount, + fee: fee ?? this.fee, + ); + } +} diff --git a/lib/features/community/providers/community_selector_provider.dart b/lib/features/community/providers/community_selector_provider.dart new file mode 100644 index 00000000..69215236 --- /dev/null +++ b/lib/features/community/providers/community_selector_provider.dart @@ -0,0 +1,109 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/core/config/communities.dart'; +import 'package:mostro_mobile/data/models/enums/storage_keys.dart'; +import 'package:mostro_mobile/data/repositories/community_repository.dart'; +import 'package:mostro_mobile/features/community/community.dart'; +import 'package:mostro_mobile/shared/providers/storage_providers.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Whether the user has already selected a community. +final communitySelectedProvider = + StateNotifierProvider>((ref) { + final prefs = ref.read(sharedPreferencesProvider); + return CommunitySelectedNotifier(prefs); +}); + +class CommunitySelectedNotifier extends StateNotifier> { + final SharedPreferencesAsync _prefs; + + CommunitySelectedNotifier(this._prefs) : super(const AsyncValue.loading()) { + _init(); + } + + Future _init() async { + try { + final selected = await _prefs.getBool( + SharedPreferencesKeys.communitySelected.value, + ); + if (selected == true) { + if (!mounted) return; + state = const AsyncValue.data(true); + return; + } + + // Backward compatibility: existing users who completed onboarding + // before community selector existed should not be interrupted. + final firstRunComplete = await _prefs.getBool( + SharedPreferencesKeys.firstRunComplete.value, + ); + if (firstRunComplete == true) { + // Auto-mark as selected so existing users are not interrupted + await _prefs.setBool( + SharedPreferencesKeys.communitySelected.value, + true, + ); + if (!mounted) return; + state = const AsyncValue.data(true); + return; + } + + if (!mounted) return; + state = const AsyncValue.data(false); + } catch (error, stackTrace) { + if (!mounted) return; + state = AsyncValue.error(error, stackTrace); + } + } + + Future markCommunitySelected() async { + try { + await _prefs.setBool( + SharedPreferencesKeys.communitySelected.value, + true, + ); + if (!mounted) return; + state = const AsyncValue.data(true); + } catch (error, stackTrace) { + if (!mounted) return; + state = AsyncValue.error(error, stackTrace); + } + } +} + +/// Provides the CommunityRepository instance. +final communityRepositoryProvider = Provider((ref) { + return CommunityRepository(); +}); + +/// Fetches community data from Nostr relays. Returns enriched Community list. +final communityListProvider = FutureProvider>((ref) async { + // Start with static config + final communities = + trustedCommunities.map((c) => Community.fromConfig(c)).toList(); + + // Fetch metadata from Nostr via repository + final repository = ref.read(communityRepositoryProvider); + final pubkeys = communities.map((c) => c.pubkey).toList(); + final metadata = await repository.fetchCommunityMetadata(pubkeys); + + // Enrich communities with fetched data and filter out nodes without + // updated trade info (no valid kind 38385 event received). + return communities + .map((community) { + final meta = metadata[community.pubkey]; + if (meta == null) return community; + + return community.copyWith( + name: meta.name, + about: meta.about, + picture: meta.picture, + hasTradeInfo: meta.hasTradeInfo, + currencies: meta.currencies, + minAmount: meta.minAmount, + maxAmount: meta.maxAmount, + fee: meta.fee, + ); + }) + .where((c) => c.hasTradeInfo) + .toList(); +}); diff --git a/lib/features/community/screens/community_selector_screen.dart b/lib/features/community/screens/community_selector_screen.dart new file mode 100644 index 00000000..2580f0d5 --- /dev/null +++ b/lib/features/community/screens/community_selector_screen.dart @@ -0,0 +1,389 @@ +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/core/app_theme.dart'; +import 'package:mostro_mobile/core/config/communities.dart'; +import 'package:mostro_mobile/features/community/community.dart'; +import 'package:mostro_mobile/features/community/providers/community_selector_provider.dart'; +import 'package:mostro_mobile/features/community/widgets/community_card.dart'; +import 'package:mostro_mobile/features/mostro/mostro_nodes_provider.dart'; +import 'package:mostro_mobile/features/mostro/widgets/add_custom_node_dialog.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/services/logger_service.dart'; + +class CommunitySelectorScreen extends ConsumerStatefulWidget { + const CommunitySelectorScreen({super.key}); + + @override + ConsumerState createState() => + _CommunitySelectorScreenState(); +} + +class _CommunitySelectorScreenState + extends ConsumerState { + String? _selectedPubkey; + String _searchQuery = ''; + bool _isSelecting = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _showDisclaimerDialog(); + }); + } + + void _showDisclaimerDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: Text(S.of(ctx)!.communityDisclaimerTitle), + content: SingleChildScrollView( + child: Text(S.of(ctx)!.communityDisclaimerBody), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(S.of(ctx)!.communityDisclaimerAccept), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final communitiesAsync = ref.watch(communityListProvider); + + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + backgroundColor: AppTheme.backgroundDark, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 32), + // Title + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.bolt, + color: AppTheme.activeColor, + size: 28, + ), + const SizedBox(width: 8), + Text( + S.of(context)!.chooseYourCommunity, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 20), + // Search bar + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: AppTheme.backgroundInput, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), + ), + ), + child: TextField( + style: const TextStyle(color: AppTheme.textPrimary), + decoration: InputDecoration( + icon: const Icon( + Icons.search, + color: AppTheme.textSecondary, + size: 20, + ), + hintText: S.of(context)!.communitySearchHint, + hintStyle: + const TextStyle(color: AppTheme.textSecondary), + border: InputBorder.none, + ), + onChanged: (value) => + setState(() => _searchQuery = value), + ), + ), + const SizedBox(height: 16), + // Community list + Expanded( + child: communitiesAsync.when( + loading: () => _buildLoadingSkeleton(), + error: (error, _) => _buildErrorState(context), + data: (communities) { + final filtered = _filterCommunities(communities); + if (filtered.isEmpty) { + return Center( + child: Text( + S.of(context)!.noCommunityResults, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 15, + ), + ), + ); + } + return ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final community = filtered[index]; + return CommunityCard( + community: community, + isSelected: _selectedPubkey == community.pubkey, + onTap: () => setState( + () => _selectedPubkey = community.pubkey, + ), + ); + }, + ); + }, + ), + ), + const SizedBox(height: 12), + // Use custom node + TextButton( + onPressed: + _isSelecting ? null : () => _onUseCustomNode(context), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.settings, + size: 18, + color: AppTheme.textSecondary, + ), + const SizedBox(width: 8), + Text( + S.of(context)!.useCustomNode, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + // Confirm button + if (_selectedPubkey != null) + ElevatedButton( + onPressed: + _isSelecting ? null : () => _onConfirm(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: _isSelecting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black, + ), + ) + : Text( + S.of(context)!.done, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + // Skip button + TextButton( + onPressed: _isSelecting ? null : () => _onSkip(context), + child: Text( + S.of(context)!.skipForNow, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + ), + SizedBox( + height: MediaQuery.of(context).viewPadding.bottom + 8, + ), + ], + ), + ), + ), + )); + } + + List _filterCommunities(List communities) { + if (_searchQuery.isEmpty) return communities; + final query = _searchQuery.toLowerCase(); + return communities.where((c) { + return c.displayName.toLowerCase().contains(query) || + c.region.toLowerCase().contains(query) || + c.currencies.any((cur) => cur.toLowerCase().contains(query)) || + (c.about?.toLowerCase().contains(query) ?? false); + }).toList(); + } + + Widget _buildLoadingSkeleton() { + return ListView.builder( + itemCount: trustedCommunities.length, + itemBuilder: (context, index) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 14, + width: 120, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + Container( + height: 10, + width: 80, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildErrorState(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.cloud_off, + color: AppTheme.textSecondary, + size: 48, + ), + const SizedBox(height: 16), + Text( + S.of(context)!.communityLoadingError, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 15, + ), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => ref.invalidate(communityListProvider), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.activeColor, + foregroundColor: Colors.black, + ), + child: Text(S.of(context)!.communityRetry), + ), + ], + ), + ); + } + + Future _onConfirm(BuildContext context) async { + if (_selectedPubkey == null) return; + await _runSelection(() => _selectAndProceed(_selectedPubkey!)); + } + + Future _onSkip(BuildContext context) async { + await _runSelection(() => _selectAndProceed(defaultMostroPubkey)); + } + + Future _onUseCustomNode(BuildContext context) async { + final pubkeysBefore = + ref.read(mostroNodesProvider).map((n) => n.pubkey).toSet(); + await AddCustomNodeDialog.show(context, ref); + if (!mounted) return; + + // Detect newly added node via set difference + final pubkeysAfter = + ref.read(mostroNodesProvider).map((n) => n.pubkey).toSet(); + final newPubkeys = pubkeysAfter.difference(pubkeysBefore); + if (newPubkeys.isNotEmpty) { + await _runSelection(() => _selectAndProceed(newPubkeys.first)); + } + } + + Future _runSelection(Future Function() action) async { + setState(() => _isSelecting = true); + try { + await action(); + } catch (e) { + logger.e('Community selection failed: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(S.of(context)!.communityLoadingError), + ), + ); + } + } finally { + if (mounted) setState(() => _isSelecting = false); + } + } + + Future _selectAndProceed(String pubkey) async { + await _ensureNodeExists(pubkey); + + final nodesNotifier = ref.read(mostroNodesProvider.notifier); + await nodesNotifier.selectNode(pubkey); + + await ref.read(communitySelectedProvider.notifier).markCommunitySelected(); + + if (mounted) { + context.go('/'); + } + } + + Future _ensureNodeExists(String pubkey) async { + final allNodes = ref.read(mostroNodesProvider); + if (allNodes.any((n) => n.pubkey == pubkey)) return; + + final notifier = ref.read(mostroNodesProvider.notifier); + await notifier.addCustomNode(pubkey); + } +} diff --git a/lib/features/community/widgets/community_card.dart b/lib/features/community/widgets/community_card.dart new file mode 100644 index 00000000..123a0671 --- /dev/null +++ b/lib/features/community/widgets/community_card.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:mostro_mobile/core/app_theme.dart'; +import 'package:mostro_mobile/features/community/community.dart'; +import 'package:mostro_mobile/generated/l10n.dart'; +import 'package:mostro_mobile/shared/providers/avatar_provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class CommunityCard extends StatelessWidget { + final Community community; + final bool isSelected; + final VoidCallback onTap; + + const CommunityCard({ + super.key, + required this.community, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.activeColor.withValues(alpha: 0.1) + : AppTheme.backgroundCard, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppTheme.activeColor.withValues(alpha: 0.5) + : Colors.white.withValues(alpha: 0.1), + width: isSelected ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: avatar + name + region + Row( + children: [ + _buildAvatar(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + community.displayName, + style: const TextStyle( + color: AppTheme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + community.region, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + ), + ), + ], + ), + ), + if (isSelected) + const Icon( + Icons.check_circle, + color: AppTheme.activeColor, + size: 24, + ), + ], + ), + + // About text + if (community.about != null && community.about!.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + community.about!, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 13, + height: 1.4, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + + // Currencies + if (community.currencies.isNotEmpty || + (community.hasTradeInfo && community.currencies.isEmpty)) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 4, + children: community.currencies.isNotEmpty + ? community.currencies.map((currency) { + return _buildCurrencyTag(currency); + }).toList() + : [ + _buildCurrencyTag( + S.of(context)!.communityAllCurrencies, + ), + ], + ), + ], + + // Stats row: fee + range + if (community.fee != null || + community.minAmount != null || + community.maxAmount != null) ...[ + const SizedBox(height: 10), + Row( + children: [ + if (community.fee != null) ...[ + Icon( + Icons.percent, + size: 14, + color: AppTheme.textSecondary.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Text( + '${S.of(context)!.communityFee} ${(community.fee! * 100).toStringAsFixed(1)}%', + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + if (community.fee != null && + (community.minAmount != null || + community.maxAmount != null)) + const SizedBox(width: 16), + if (community.minAmount != null || + community.maxAmount != null) ...[ + Icon( + Icons.bar_chart, + size: 14, + color: AppTheme.textSecondary.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Text( + _formatRange(context), + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + ], + ), + ], + + // Social links + if (community.social.isNotEmpty) ...[ + const SizedBox(height: 10), + Row( + children: community.social.map((link) { + return Padding( + padding: const EdgeInsets.only(right: 12), + child: GestureDetector( + onTap: () => _launchUrl(link.url), + child: Icon( + _socialIcon(link.type), + size: 18, + color: AppTheme.textSecondary, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ); + } + + Widget _buildCurrencyTag(String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppTheme.activeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label, + style: const TextStyle( + color: AppTheme.activeColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + Widget _buildAvatar() { + if (community.picture != null) { + return ClipOval( + child: Image.network( + community.picture!, + width: 44, + height: 44, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return NymAvatar(pubkeyHex: community.pubkey, size: 44); + }, + errorBuilder: (_, __, ___) => + NymAvatar(pubkeyHex: community.pubkey, size: 44), + ), + ); + } + return NymAvatar(pubkeyHex: community.pubkey, size: 44); + } + + String _formatRange(BuildContext context) { + final min = community.minAmount; + final max = community.maxAmount; + if (min != null && max != null) { + return '${_formatSats(context, min)} - ${_formatSats(context, max)}'; + } + if (min != null) return '${_formatSats(context, min)}+'; + if (max != null) return '< ${_formatSats(context, max)}'; + return ''; + } + + String _formatSats(BuildContext context, int amount) { + final locale = Localizations.localeOf(context).toString(); + final formatted = amount < 1000 + ? NumberFormat.decimalPattern(locale).format(amount) + : NumberFormat.compact(locale: locale).format(amount); + return '$formatted sats'; + } + + IconData _socialIcon(String type) { + switch (type) { + case 'telegram': + return Icons.telegram; + case 'x': + return Icons.alternate_email; + case 'instagram': + return Icons.camera_alt_outlined; + default: + return Icons.link; + } + } + + Future _launchUrl(String url) async { + final uri = Uri.tryParse(url); + if (uri == null) return; + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + // Silently fail - social links are non-critical + } + } +} diff --git a/lib/features/order/providers/order_notifier_provider.dart b/lib/features/order/providers/order_notifier_provider.dart index f56112e2..c9ae6f9a 100644 --- a/lib/features/order/providers/order_notifier_provider.dart +++ b/lib/features/order/providers/order_notifier_provider.dart @@ -5,8 +5,6 @@ import 'package:mostro_mobile/features/order/models/order_state.dart'; import 'package:mostro_mobile/features/order/notifiers/add_order_notifier.dart'; import 'package:mostro_mobile/features/order/notifiers/order_notifier.dart'; import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -part 'order_notifier_provider.g.dart'; final orderNotifierProvider = StateNotifierProvider.family( @@ -28,15 +26,17 @@ final addOrderNotifierProvider = }, ); -// This provider tracks the currently selected OrderType tab -@riverpod -class OrderTypeNotifier extends _$OrderTypeNotifier { - @override - OrderType build() => OrderType.sell; +class OrderTypeNotifier extends StateNotifier { + OrderTypeNotifier() : super(OrderType.sell); void set(OrderType value) => state = value; } +final orderTypeNotifierProvider = + AutoDisposeStateNotifierProvider((ref) { + return OrderTypeNotifier(); +}); + final addOrderEventsProvider = StreamProvider.family( (ref, requestId) { final storage = ref.watch(mostroStorageProvider); diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index e1062de1..3609d482 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -751,6 +751,22 @@ class _SettingsScreenState extends ConsumerState { ), ), ), + Container( + margin: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber.withValues(alpha: 0.3)), + ), + child: Text( + S.of(context)!.communityDisclaimerBody, + style: TextStyle( + fontSize: 12, + color: Colors.amber.shade900, + ), + ), + ), ], ), ), diff --git a/lib/features/walkthrough/screens/walkthrough_screen.dart b/lib/features/walkthrough/screens/walkthrough_screen.dart index 665c7ca4..81d8d3ca 100644 --- a/lib/features/walkthrough/screens/walkthrough_screen.dart +++ b/lib/features/walkthrough/screens/walkthrough_screen.dart @@ -171,7 +171,7 @@ class _WalkthroughScreenState extends ConsumerState { // Show backup reminder for first-time users ref.read(backupReminderProvider.notifier).showBackupReminder(); if (context.mounted) { - context.go('/'); + context.go('/community_selector'); } } diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 607b0238..1e9d6102 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1511,5 +1511,25 @@ "deepLinkDifferentMostroBody": "Diese Order wurde auf einem anderen Mostro-Knoten erstellt. Möchtest du zu diesem Mostro wechseln und die Order ansehen?", "deepLinkDifferentMostroFrom": "Order von:", "deepLinkDifferentMostroCurrent": "Aktuell verbunden mit:", - "deepLinkSwitchAndView": "Wechseln und ansehen" -} \ No newline at end of file + "deepLinkSwitchAndView": "Wechseln und ansehen", + "chooseYourCommunity": "Wähle deine Community", + "communitySearchHint": "Communities suchen...", + "communityFee": "Gebühr", + "communityRange": "Bereich", + "useCustomNode": "Eigenen Knoten verwenden", + "communityLoadingError": "Community-Daten konnten nicht geladen werden", + "communityRetry": "Erneut versuchen", + "noCommunityResults": "Keine Communities gefunden", + "communityFormatSats": "{amount} sats", + "@communityFormatSats": { + "placeholders": { + "amount": { + "type": "String" + } + } + }, + "communityAllCurrencies": "Alle Währungen", + "communityDisclaimerTitle": "Wichtiger Hinweis", + "communityDisclaimerBody": "Das Mostro-Entwicklungsteam ist nicht verantwortlich fuer die Nutzung der Plattform durch Node-Betreiber. Jeder Betreiber kontrolliert seinen eigenen Mostro-Node und ist allein verantwortlich fuer seine Handlungen. Mit der Nutzung von Mostro akzeptieren Sie die volle Verantwortung fuer Ihre Transaktionen und erkennen an, dass das Entwicklungsteam keine Kontrolle ueber einzelne Node-Betreiber hat.", + "communityDisclaimerAccept": "Akzeptieren" +} diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 43775780..3d562bea 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1511,5 +1511,25 @@ "deepLinkDifferentMostroBody": "This order was created on a different Mostro node. Do you want to switch to this Mostro and view the order?", "deepLinkDifferentMostroFrom": "Order from:", "deepLinkDifferentMostroCurrent": "Currently connected to:", - "deepLinkSwitchAndView": "Switch & View" -} \ No newline at end of file + "deepLinkSwitchAndView": "Switch & View", + "chooseYourCommunity": "Choose your community", + "communitySearchHint": "Search communities...", + "communityFee": "Fee", + "communityRange": "Range", + "useCustomNode": "Use a custom node", + "communityLoadingError": "Could not load community data", + "communityRetry": "Retry", + "noCommunityResults": "No communities found", + "communityFormatSats": "{amount} sats", + "@communityFormatSats": { + "placeholders": { + "amount": { + "type": "String" + } + } + }, + "communityAllCurrencies": "All currencies", + "communityDisclaimerTitle": "Important Notice", + "communityDisclaimerBody": "The Mostro development team is not responsible for how node operators use the platform. Each operator controls their own Mostro node and is solely responsible for their actions. By using Mostro, you accept full responsibility for your trades and acknowledge that the development team has no control over individual node operators.", + "communityDisclaimerAccept": "Accept" +} diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index d87907cd..269d7850 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1486,5 +1486,25 @@ "deepLinkDifferentMostroBody": "Esta orden fue creada en un nodo Mostro diferente. ¿Deseas cambiar a este Mostro y ver la orden?", "deepLinkDifferentMostroFrom": "Orden de:", "deepLinkDifferentMostroCurrent": "Actualmente conectado a:", - "deepLinkSwitchAndView": "Cambiar y ver" -} \ No newline at end of file + "deepLinkSwitchAndView": "Cambiar y ver", + "chooseYourCommunity": "Elige tu comunidad", + "communitySearchHint": "Buscar comunidades...", + "communityFee": "Comisión", + "communityRange": "Rango", + "useCustomNode": "Usar un nodo personalizado", + "communityLoadingError": "No se pudieron cargar los datos de la comunidad", + "communityRetry": "Reintentar", + "noCommunityResults": "No se encontraron comunidades", + "communityFormatSats": "{amount} sats", + "@communityFormatSats": { + "placeholders": { + "amount": { + "type": "String" + } + } + }, + "communityAllCurrencies": "Todas las monedas", + "communityDisclaimerTitle": "Aviso Importante", + "communityDisclaimerBody": "El equipo de desarrollo de Mostro no se hace responsable del uso que los operadores de nodos hagan de la plataforma. Cada operador controla su propio nodo Mostro y es el unico responsable de sus acciones. Al usar Mostro, aceptas la plena responsabilidad de tus operaciones y reconoces que el equipo de desarrollo no tiene control sobre los operadores de nodos individuales.", + "communityDisclaimerAccept": "Aceptar" +} diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 20b83b66..34130458 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1511,5 +1511,25 @@ "deepLinkDifferentMostroBody": "Cette commande a été créée sur un nœud Mostro différent. Voulez-vous basculer vers ce Mostro et voir la commande ?", "deepLinkDifferentMostroFrom": "Commande de :", "deepLinkDifferentMostroCurrent": "Actuellement connecté à :", - "deepLinkSwitchAndView": "Basculer et voir" -} \ No newline at end of file + "deepLinkSwitchAndView": "Basculer et voir", + "chooseYourCommunity": "Choisissez votre communauté", + "communitySearchHint": "Rechercher des communautés...", + "communityFee": "Frais", + "communityRange": "Plage", + "useCustomNode": "Utiliser un nœud personnalisé", + "communityLoadingError": "Impossible de charger les données de la communauté", + "communityRetry": "Réessayer", + "noCommunityResults": "Aucune communauté trouvée", + "communityFormatSats": "{amount} sats", + "@communityFormatSats": { + "placeholders": { + "amount": { + "type": "String" + } + } + }, + "communityAllCurrencies": "Toutes les devises", + "communityDisclaimerTitle": "Avis Important", + "communityDisclaimerBody": "L'equipe de developpement de Mostro n'est pas responsable de l'utilisation que font les operateurs de noeuds de la plateforme. Chaque operateur controle son propre noeud Mostro et est seul responsable de ses actions. En utilisant Mostro, vous acceptez l'entiere responsabilite de vos transactions et reconnaissez que l'equipe de developpement n'a aucun controle sur les operateurs de noeuds individuels.", + "communityDisclaimerAccept": "Accepter" +} diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 489c1bd6..5c229ca6 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1545,5 +1545,25 @@ "deepLinkDifferentMostroBody": "Questo ordine è stato creato su un nodo Mostro diverso. Vuoi passare a questo Mostro e visualizzare l'ordine?", "deepLinkDifferentMostroFrom": "Ordine da:", "deepLinkDifferentMostroCurrent": "Attualmente connesso a:", - "deepLinkSwitchAndView": "Cambia e visualizza" -} \ No newline at end of file + "deepLinkSwitchAndView": "Cambia e visualizza", + "chooseYourCommunity": "Scegli la tua comunità", + "communitySearchHint": "Cerca comunità...", + "communityFee": "Commissione", + "communityRange": "Intervallo", + "useCustomNode": "Usa un nodo personalizzato", + "communityLoadingError": "Impossibile caricare i dati della comunità", + "communityRetry": "Riprova", + "noCommunityResults": "Nessuna comunità trovata", + "communityFormatSats": "{amount} sats", + "@communityFormatSats": { + "placeholders": { + "amount": { + "type": "String" + } + } + }, + "communityAllCurrencies": "Tutte le valute", + "communityDisclaimerTitle": "Avviso Importante", + "communityDisclaimerBody": "Il team di sviluppo di Mostro non e' responsabile di come gli operatori dei nodi utilizzano la piattaforma. Ogni operatore controlla il proprio nodo Mostro ed e' l'unico responsabile delle proprie azioni. Utilizzando Mostro, accetti la piena responsabilita' per le tue operazioni e riconosci che il team di sviluppo non ha alcun controllo sui singoli operatori dei nodi.", + "communityDisclaimerAccept": "Accetta" +} diff --git a/lib/services/event_bus.dart b/lib/services/event_bus.dart index 141ec4a9..5a991136 100644 --- a/lib/services/event_bus.dart +++ b/lib/services/event_bus.dart @@ -1,8 +1,6 @@ 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(); @@ -14,9 +12,8 @@ class EventBus { void dispose() => _controller.close(); } -@riverpod -EventBus eventBus(Ref ref) { +final eventBusProvider = AutoDisposeProvider((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 a7b524a5..5c4e28a9 100644 --- a/lib/services/mostro_service.dart +++ b/lib/services/mostro_service.dart @@ -25,6 +25,9 @@ class MostroService { MostroService(this.ref) : _settings = ref.read(settingsProvider); void init() { + // Cancel any existing subscription to prevent leaks on re-init + _ordersSubscription?.cancel(); + // Subscribe to the orders stream from SubscriptionManager // The SubscriptionManager will automatically manage subscriptions based on SessionNotifier changes _ordersSubscription = ref diff --git a/lib/shared/providers/mostro_service_provider.dart b/lib/shared/providers/mostro_service_provider.dart index f92efa45..e7c72b01 100644 --- a/lib/shared/providers/mostro_service_provider.dart +++ b/lib/shared/providers/mostro_service_provider.dart @@ -3,28 +3,23 @@ import 'package:mostro_mobile/data/repositories/event_storage.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_database_provider.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -part 'mostro_service_provider.g.dart'; - -@Riverpod(keepAlive: true) -EventStorage eventStorage(Ref ref) { +final eventStorageProvider = Provider((ref) { final db = ref.watch(eventDatabaseProvider); return EventStorage(db: db); -} +}); -@Riverpod(keepAlive: true) -MostroService mostroService(Ref ref) { +final mostroServiceProvider = Provider((ref) { final mostroService = MostroService(ref); mostroService.init(); - + ref.listen(settingsProvider, (previous, next) { mostroService.updateSettings(next); }); - + ref.onDispose(() { mostroService.dispose(); }); - + return mostroService; -} +}); diff --git a/pubspec.lock b/pubspec.lock index c449bf23..a47684f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "93.0.0" _flutterfire_internals: dependency: transitive description: @@ -21,18 +21,10 @@ packages: dependency: transitive description: name: analyzer - sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "7.6.0" - analyzer_plugin: - dependency: transitive - description: - name: analyzer_plugin - sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce - url: "https://pub.dev" - source: hosted - version: "0.13.4" + version: "10.0.1" app_links: dependency: "direct main" description: @@ -165,18 +157,18 @@ packages: dependency: transitive description: name: build - sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "4.0.5" build_config: dependency: transitive description: name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.3.0" build_daemon: dependency: transitive description: @@ -185,30 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.4" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 - url: "https://pub.dev" - source: hosted - version: "2.5.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e" url: "https://pub.dev" source: hosted - version: "2.5.4" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" - url: "https://pub.dev" - source: hosted - version: "9.1.2" + version: "2.13.1" built_collection: dependency: transitive description: @@ -229,10 +205,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -337,38 +313,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: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" - url: "https://pub.dev" - source: hosted - version: "1.0.0+7.7.0" dart_nostr: dependency: "direct main" description: name: dart_nostr - sha256: d7ffb5159be6ab174c8af4457d5112c925eb2c2294ce1251707ae680c90e15db + sha256: d526a8b62ba023e650d2285af2c3a3e7bcf56cdb7a306086c3397b0b93bdd148 url: "https://pub.dev" source: hosted - version: "9.1.1" + version: "9.2.5" dart_style: dependency: transitive description: name: dart_style - sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.7" dbus: dependency: transitive description: @@ -770,14 +730,6 @@ packages: description: flutter source: sdk version: "0.0.0" - freezed_annotation: - dependency: transitive - description: - name: freezed_annotation - sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" - url: "https://pub.dev" - source: hosted - version: "3.1.0" frontend_server_client: dependency: transitive description: @@ -811,10 +763,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 url: "https://pub.dev" source: hosted - version: "6.2.1" + version: "6.3.3" graphs: dependency: transitive description: @@ -1104,26 +1056,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: "direct main" description: @@ -1144,10 +1096,10 @@ packages: dependency: "direct dev" description: name: mockito - sha256: "4546eac99e8967ea91bae633d2ca7698181d008e95fa4627330cf903d573277a" + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 url: "https://pub.dev" source: hosted - version: "5.4.6" + version: "5.6.4" mutation_test: dependency: "direct dev" description: @@ -1453,30 +1405,6 @@ 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: @@ -1606,10 +1534,10 @@ packages: dependency: transitive description: name: source_gen - sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "4.2.2" source_map_stack_trace: dependency: transitive description: @@ -1710,26 +1638,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.16" timeago: dependency: "direct main" description: @@ -1746,14 +1674,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.10.1" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" typed_data: dependency: transitive description: @@ -1890,14 +1810,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "3.0.3" webdriver: dependency: transitive description: @@ -1947,5 +1875,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <=3.9.9" - flutter: ">=3.32.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2c2e7517..03a24adc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,7 +63,6 @@ dependencies: sembast_web: ^2.4.1 circular_countdown: ^2.1.0 introduction_screen: ^3.1.17 - riverpod_annotation: ^2.6.1 lucide_icons: ^0.257.0 url_launcher: ^6.3.2 @@ -125,8 +124,6 @@ dev_dependencies: flutter_intl: ^0.0.1 mockito: ^5.4.5 build_runner: ^2.4.0 - riverpod_generator: ^2.6.5 - # Mutation testing for test quality assurance mutation_test: ^1.8.0 diff --git a/test/data/repositories/community_signature_test.dart b/test/data/repositories/community_signature_test.dart new file mode 100644 index 00000000..ca4a3143 --- /dev/null +++ b/test/data/repositories/community_signature_test.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:dart_nostr/dart_nostr.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/repositories/community_repository.dart'; +import 'package:mostro_mobile/shared/utils/nostr_utils.dart'; + +void main() { + late CommunityRepository repository; + + setUp(() { + repository = CommunityRepository(); + }); + + group('CommunityRepository event verification', () { + test('accepts valid kind 0 event', () { + final keyPair = NostrUtils.generateKeyPair(); + final event = NostrEvent.fromPartialData( + kind: 0, + content: '{"name":"Test","about":"A test node"}', + keyPairs: keyPair, + tags: [ + ['p', 'deadbeef'], + ], + ); + + expect(repository.verifyEvent(event.toMap()), isTrue); + }); + + test('accepts valid kind 38385 event with empty content', () { + final keyPair = NostrUtils.generateKeyPair(); + final event = NostrEvent.fromPartialData( + kind: 38385, + content: '', + keyPairs: keyPair, + tags: [ + ['y', 'mostro'], + ['fiat_currencies_accepted', 'USD,EUR'], + ['fee', '0.01'], + ], + ); + + expect(repository.verifyEvent(event.toMap()), isTrue); + }); + + test('rejects event with tampered content', () { + final keyPair = NostrUtils.generateKeyPair(); + final event = NostrEvent.fromPartialData( + kind: 0, + content: '{"name":"Real Node"}', + keyPairs: keyPair, + ); + + final tampered = Map.from(event.toMap()); + tampered['content'] = '{"name":"Spoofed Node"}'; + + expect(repository.verifyEvent(tampered), isFalse); + }); + + test('rejects event with tampered pubkey', () { + final keyPair = NostrUtils.generateKeyPair(); + final otherKeyPair = NostrUtils.generateKeyPair(); + final event = NostrEvent.fromPartialData( + kind: 0, + content: '{"name":"Real"}', + keyPairs: keyPair, + ); + + final tampered = Map.from(event.toMap()); + tampered['pubkey'] = otherKeyPair.public; + + expect(repository.verifyEvent(tampered), isFalse); + }); + + test('rejects event with forged id and signature', () { + final keyPair = NostrUtils.generateKeyPair(); + final attackerKeyPair = NostrUtils.generateKeyPair(); + + final original = NostrEvent.fromPartialData( + kind: 0, + content: '{"name":"Real"}', + keyPairs: keyPair, + ); + + final spoofedContent = '{"name":"Spoofed"}'; + final originalMap = original.toMap(); + final serialized = jsonEncode([ + 0, + originalMap['pubkey'], + originalMap['created_at'], + originalMap['kind'], + originalMap['tags'] ?? [], + spoofedContent, + ]); + final forgedId = sha256.convert(utf8.encode(serialized)).toString(); + final forgedSig = attackerKeyPair.sign(forgedId); + + final tampered = Map.from(originalMap); + tampered['content'] = spoofedContent; + tampered['id'] = forgedId; + tampered['sig'] = forgedSig; + + expect(repository.verifyEvent(tampered), isFalse); + }); + + test('handles event with null content as empty string', () { + final keyPair = NostrUtils.generateKeyPair(); + final event = NostrEvent.fromPartialData( + kind: 38385, + content: '', + keyPairs: keyPair, + tags: [ + ['y', 'mostro'], + ], + ); + + final rawMap = event.toMap(); + rawMap.remove('content'); + + expect(repository.verifyEvent(rawMap), isTrue); + }); + }); +} diff --git a/test/features/mostro/mostro_nodes_notifier_test.dart b/test/features/mostro/mostro_nodes_notifier_test.dart index 0e5d1b90..30f08f8c 100644 --- a/test/features/mostro/mostro_nodes_notifier_test.dart +++ b/test/features/mostro/mostro_nodes_notifier_test.dart @@ -65,7 +65,7 @@ void main() { expect(notifier.trustedNodes.length, Config.trustedMostroNodes.length); expect(notifier.trustedNodes.first.pubkey, trustedPubkey); expect(notifier.trustedNodes.first.isTrusted, true); - expect(notifier.trustedNodes.first.name, 'Mostro P2P'); + expect(notifier.trustedNodes.first.name, Config.trustedMostroNodes.first['name']); }); test('init loads custom nodes from SharedPreferences', () async { @@ -323,7 +323,7 @@ void main() { await notifier.updateCustomNodeName(trustedPubkey, 'Hacked Name'); final trusted = notifier.trustedNodes.first; - expect(trusted.name, 'Mostro P2P'); + expect(trusted.name, Config.trustedMostroNodes.first['name']); }); test('updateNodeMetadata updates metadata for any node', () async {