diff --git a/lib/main.dart b/lib/main.dart index f8c95e3..092e388 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,7 @@ -import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'dart:async'; + +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:provider/provider.dart'; @@ -19,6 +22,7 @@ import 'services/voice_codec_service.dart'; import 'services/voice_player_service.dart'; import 'services/notification_service.dart'; import 'services/locale_preferences.dart'; +import 'services/mesh_map_nodes_service.dart'; import 'services/update_checker_service.dart'; import 'services/wizard_preferences.dart'; import 'screens/home_screen.dart'; @@ -70,6 +74,9 @@ class _MeshCoreSarAppState extends State { // Shows notification if update is available _checkForUpdates(); + // Refresh remote mesh node cache in background when the app starts. + unawaited(MeshMapNodesService.syncInBackgroundIfStale()); + setState(() { _wizardCompleted = wizardCompleted; _isInitialized = true; diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 7a22043..8ac194b 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -7,6 +7,7 @@ import '../models/contact.dart'; import '../providers/contacts_provider.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; +import '../utils/contact_grouping.dart'; import '../widgets/contacts/contact_tile.dart'; import '../widgets/contacts/add_channel_dialog.dart'; @@ -27,6 +28,7 @@ class ContactsTab extends StatefulWidget { class _ContactsTabState extends State { Position? _currentPosition; final Set _resolvingAdvertKeys = {}; + ContactSortMode _sortMode = ContactSortMode.lastSeen; @override void initState() { @@ -125,20 +127,22 @@ class _ContactsTabState extends State { return l10n.daysAgo(diff.inDays); } - List _sortContactsByDistance(List contacts) { + List _sortContacts(List contacts) { final sorted = List.from(contacts); sorted.sort((a, b) { - final distanceA = _distanceFromCurrentPosition(a); - final distanceB = _distanceFromCurrentPosition(b); + if (_sortMode == ContactSortMode.distance) { + final distanceA = _distanceFromCurrentPosition(a); + final distanceB = _distanceFromCurrentPosition(b); - if (distanceA != null && distanceB != null) { - final distanceCompare = distanceA.compareTo(distanceB); - if (distanceCompare != 0) return distanceCompare; - } else if (distanceA != null) { - return -1; - } else if (distanceB != null) { - return 1; + if (distanceA != null && distanceB != null) { + final distanceCompare = distanceA.compareTo(distanceB); + if (distanceCompare != 0) return distanceCompare; + } else if (distanceA != null) { + return -1; + } else if (distanceB != null) { + return 1; + } } return b.lastSeenTime.compareTo(a.lastSeenTime); @@ -208,12 +212,10 @@ class _ContactsTabState extends State { return Scaffold( body: Consumer( builder: (context, contactsProvider, child) { - final chatContacts = _sortContactsByDistance( - contactsProvider.chatContacts, - ); - final repeaters = _sortContactsByDistance(contactsProvider.repeaters); - final rooms = _sortContactsByDistance(contactsProvider.rooms); - final channels = _sortContactsByDistance(contactsProvider.channels); + final chatContacts = _sortContacts(contactsProvider.chatContacts); + final repeaters = _sortContacts(contactsProvider.repeaters); + final rooms = _sortContacts(contactsProvider.rooms); + final channels = _sortContacts(contactsProvider.channels); final pendingAdverts = contactsProvider.pendingAdverts; // Check if there are any displayable contacts @@ -255,6 +257,17 @@ class _ContactsTabState extends State { child: ListView( padding: const EdgeInsets.all(8), children: [ + _SortModeSwitcher( + sortMode: _sortMode, + lastSeenLabel: l10n.lastSeen, + distanceLabel: l10n.distance, + onChanged: (sortMode) { + setState(() { + _sortMode = sortMode; + }); + }, + ), + // Pending adverts (public key only; quick resolve) if (pendingAdverts.isNotEmpty) ...[ _SectionHeader( @@ -283,16 +296,7 @@ class _ContactsTabState extends State { count: chatContacts.length, icon: Icons.people, ), - ...chatContacts.map( - (contact) => ContactTile( - contact: contact, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - onNavigateToMessages: widget.onNavigateToMessages, - ), - ), + ..._buildContactSectionItems(chatContacts), const Divider(height: 32), ], @@ -303,16 +307,7 @@ class _ContactsTabState extends State { count: repeaters.length, icon: Icons.router, ), - ...repeaters.map( - (contact) => ContactTile( - contact: contact, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - onNavigateToMessages: widget.onNavigateToMessages, - ), - ), + ..._buildContactSectionItems(repeaters), const Divider(height: 32), ], @@ -323,16 +318,7 @@ class _ContactsTabState extends State { count: rooms.length, icon: Icons.tag, ), - ...rooms.map( - (contact) => ContactTile( - contact: contact, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - onNavigateToMessages: widget.onNavigateToMessages, - ), - ), + ..._buildContactSectionItems(rooms), const Divider(height: 32), ], @@ -343,16 +329,7 @@ class _ContactsTabState extends State { icon: Icons.broadcast_on_personal, ), if (channels.isNotEmpty) ...[ - ...channels.map( - (contact) => ContactTile( - contact: contact, - currentPosition: _currentPosition, - calculateDistance: _calculateDistanceInMeters, - formatDistance: _formatDistance, - onNavigateToMap: widget.onNavigateToMap, - onNavigateToMessages: widget.onNavigateToMessages, - ), - ), + ..._buildContactSectionItems(channels), ], // Add Channel Button (visible in both simple and advanced mode, only show when connected) @@ -381,8 +358,37 @@ class _ContactsTabState extends State { ), ); } + + List _buildContactSectionItems(List contacts) { + final items = ContactGrouping.buildItemsFromSorted(contacts); + + return items.map((item) { + if (item.isGroup) { + return _InferredContactGroupCard( + label: item.group!.label, + contacts: item.group!.contacts, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, + ); + } + + return ContactTile( + contact: item.contact!, + currentPosition: _currentPosition, + calculateDistance: _calculateDistanceInMeters, + formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, + onNavigateToMessages: widget.onNavigateToMessages, + ); + }).toList(); + } } +enum ContactSortMode { lastSeen, distance } + class _PendingAdvertTile extends StatelessWidget { final PendingAdvert advert; final String subtitle; @@ -465,3 +471,135 @@ class _SectionHeader extends StatelessWidget { ); } } + +class _InferredContactGroupCard extends StatelessWidget { + final String label; + final List contacts; + final Position? currentPosition; + final double Function(double, double, double, double) calculateDistance; + final String Function(double) formatDistance; + final VoidCallback? onNavigateToMap; + final VoidCallback? onNavigateToMessages; + + const _InferredContactGroupCard({ + required this.label, + required this.contacts, + required this.currentPosition, + required this.calculateDistance, + required this.formatDistance, + required this.onNavigateToMap, + required this.onNavigateToMessages, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.folder_copy_outlined, + size: 18, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + label, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + contacts.length.toString(), + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ], + ), + const SizedBox(height: 10), + ...contacts.map( + (contact) => ContactTile( + contact: contact, + currentPosition: currentPosition, + calculateDistance: calculateDistance, + formatDistance: formatDistance, + onNavigateToMap: onNavigateToMap, + onNavigateToMessages: onNavigateToMessages, + ), + ), + ], + ), + ), + ); + } +} + +class _SortModeSwitcher extends StatelessWidget { + final ContactSortMode sortMode; + final String lastSeenLabel; + final String distanceLabel; + final ValueChanged onChanged; + + const _SortModeSwitcher({ + required this.sortMode, + required this.lastSeenLabel, + required this.distanceLabel, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Align( + alignment: Alignment.centerLeft, + child: SegmentedButton( + segments: [ + ButtonSegment( + value: ContactSortMode.lastSeen, + label: Text(lastSeenLabel), + icon: const Icon(Icons.schedule), + ), + ButtonSegment( + value: ContactSortMode.distance, + label: Text(distanceLabel), + icon: const Icon(Icons.near_me), + ), + ], + selected: {sortMode}, + onSelectionChanged: (selection) { + final selected = selection.isEmpty ? null : selection.first; + if (selected != null) { + onChanged(selected); + } + }, + showSelectedIcon: false, + ), + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index aea34a3..4e19f5d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -24,6 +24,7 @@ import '../widgets/permission_request_dialog.dart'; import '../widgets/connection_dialog.dart'; import '../utils/battery_display_helper.dart'; import '../services/developer_mode_service.dart'; +import '../services/mesh_map_nodes_service.dart'; enum _HomeTab { messages, contacts, sensors, map } @@ -93,6 +94,7 @@ class _HomeScreenState extends State _initTabController(); _loadRxTxPreference(); _loadDeveloperModePreference(); + MeshMapNodesService.syncInBackgroundIfStale(); // Show permission dialog after the first frame if needed if (widget.shouldShowPermissionDialog) { @@ -219,6 +221,9 @@ class _HomeScreenState extends State void didChangeAppLifecycleState(AppLifecycleState state) { _lifecycleState = state; _syncFastLocationUiState(); + if (state == AppLifecycleState.resumed) { + MeshMapNodesService.syncInBackgroundIfStale(); + } } Future _loadRxTxPreference() async { @@ -388,10 +393,14 @@ class _HomeScreenState extends State required int count, required bool isActive, required Color activeColor, + bool compact = false, }) { final color = isActive ? activeColor : Colors.grey; return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + padding: EdgeInsets.symmetric( + horizontal: compact ? 7 : 8, + vertical: compact ? 4 : 5, + ), decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(999), @@ -400,15 +409,15 @@ class _HomeScreenState extends State mainAxisSize: MainAxisSize.min, children: [ Container( - width: 7, - height: 7, + width: compact ? 6 : 7, + height: compact ? 6 : 7, decoration: BoxDecoration(shape: BoxShape.circle, color: color), ), - const SizedBox(width: 6), + SizedBox(width: compact ? 5 : 6), Text( '$label:$count', style: TextStyle( - fontSize: 11, + fontSize: compact ? 10 : 11, fontWeight: FontWeight.w600, color: color, ), @@ -1036,17 +1045,19 @@ class _HomeScreenState extends State txActive: provider.txActivity, ) : Container( + constraints: const BoxConstraints(minHeight: 48), padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, + horizontal: 8, + vertical: 4, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh .withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(22), ), child: Column( mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ _buildActivityBadge( @@ -1054,13 +1065,15 @@ class _HomeScreenState extends State count: provider.rxPacketCount, isActive: provider.rxActivity, activeColor: Colors.green, + compact: true, ), - const SizedBox(height: 6), + const SizedBox(height: 4), _buildActivityBadge( label: 'TX', count: provider.txPacketCount, isActive: provider.txActivity, activeColor: Colors.blue, + compact: true, ), ], ), diff --git a/lib/screens/packet_log_screen.dart b/lib/screens/packet_log_screen.dart index 93721d5..ff06ec0 100644 --- a/lib/screens/packet_log_screen.dart +++ b/lib/screens/packet_log_screen.dart @@ -8,6 +8,7 @@ import 'package:meshcore_client/meshcore_client.dart'; import '../l10n/app_localizations.dart'; import '../providers/connection_provider.dart'; import '../providers/contacts_provider.dart'; +import '../services/mesh_map_nodes_service.dart'; import '../services/route_hash_preferences.dart'; import '../utils/log_rx_route_decoder.dart'; @@ -746,22 +747,31 @@ class _DecodedRouteSection extends StatelessWidget { connectionProvider.deviceInfo.selfName ?? connectionProvider.deviceInfo.displayName; - return FutureBuilder( - future: RouteHashPreferences.getHashSize(), + return FutureBuilder>( + future: Future.wait([ + RouteHashPreferences.getHashSize(), + MeshMapNodesService.loadCachedNodes( + cacheTtl: MeshMapNodesService.traceCacheTtl, + ), + ]), builder: (context, snapshot) { final decodedRoute = LogRxRouteDecoder.decode( log.rawData, - preferredHashSize: snapshot.data, + preferredHashSize: snapshot.data?.first as int?, ); if (decodedRoute == null) { return const SizedBox.shrink(); } + final cachedNodes = snapshot.hasData + ? snapshot.data![1] as List + : const []; final resolvedPath = decodedRoute.hopHashes .map( - (hashHex) => LogRxRouteDecoder.resolveHash( + (hashHex) => _resolveHashWithFallback( hashHex, contacts: contacts, + cachedNodes: cachedNodes, ownPublicKey: ownPublicKey, ownName: ownName, ), @@ -777,6 +787,41 @@ class _DecodedRouteSection extends StatelessWidget { }, ); } + + ResolvedNodeHash _resolveHashWithFallback( + String hashHex, { + required List contacts, + required List cachedNodes, + required Uint8List? ownPublicKey, + required String? ownName, + }) { + final localResolved = LogRxRouteDecoder.resolveHash( + hashHex, + contacts: contacts, + ownPublicKey: ownPublicKey, + ownName: ownName, + ); + if (localResolved.matchCount > 0 || localResolved.isOwnNode) { + return localResolved; + } + + final normalizedHashHex = hashHex.toLowerCase(); + final matches = cachedNodes + .where((node) => node.publicKey.startsWith(normalizedHashHex)) + .toList(); + if (matches.isEmpty) { + return localResolved; + } + + matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs)); + return ResolvedNodeHash( + hashHex: normalizedHashHex, + label: matches.first.name, + isOwnNode: false, + isUniqueMatch: matches.length == 1, + matchCount: matches.length, + ); + } } class _RouteSection extends StatelessWidget { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f7098ab..258dca6 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; -import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_avif/flutter_avif.dart'; import 'package:image_picker/image_picker.dart'; @@ -15,6 +16,7 @@ import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; import '../services/location_tracking_service.dart'; import '../services/locale_preferences.dart'; +import '../services/mesh_map_nodes_service.dart'; import '../services/update_checker_service.dart'; import '../services/voice_codec_service.dart'; import '../services/voice_bitrate_preferences.dart'; @@ -72,6 +74,8 @@ class _SettingsScreenState extends State { double _fastLocationMovementThresholdMeters = 10.0; int _fastLocationActiveCadenceSeconds = 10; bool _isDeveloperModeEnabled = false; + DateTime? _onlineTraceCacheUpdatedAt; + bool _isClearingOnlineTraceCache = false; int _versionTapCount = 0; final ImagePicker _imagePicker = ImagePicker(); final LocationTrackingService _locationService = LocationTrackingService(); @@ -89,6 +93,7 @@ class _SettingsScreenState extends State { _loadImagePreferences(); _loadFastLocationSettings(); _loadDeveloperMode(); + _loadOnlineTraceCacheStatus(); } @override @@ -126,6 +131,14 @@ class _SettingsScreenState extends State { }); } + Future _loadOnlineTraceCacheStatus() async { + final cachedAt = await MeshMapNodesService.cachedAt(); + if (!mounted) return; + setState(() { + _onlineTraceCacheUpdatedAt = cachedAt; + }); + } + Future _handleVersionTap() async { if (_isDeveloperModeEnabled) { await DeveloperModeService.setEnabled(false); @@ -829,6 +842,66 @@ class _SettingsScreenState extends State { ); } + Future _clearOnlineTraceCache() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear online trace database'), + content: const Text( + 'This removes the cached online node database used as a trace fallback.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.clear), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + setState(() { + _isClearingOnlineTraceCache = true; + }); + + await MeshMapNodesService.clearCache(); + + if (!mounted) return; + setState(() { + _onlineTraceCacheUpdatedAt = null; + _isClearingOnlineTraceCache = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Online trace database cleared'), + backgroundColor: Colors.orange, + ), + ); + } + + String _onlineTraceCacheSubtitle() { + final cachedAt = _onlineTraceCacheUpdatedAt; + if (cachedAt == null) { + return 'No cached online database. Refresh runs in background when internet is available.'; + } + + final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl); + return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.'; + } + + String _formatDateTime(DateTime value) { + final local = value.toLocal(); + String two(int part) => part.toString().padLeft(2, '0'); + return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}'; + } + Future _showRouteHashSizeDialog() async { final selected = await showDialog( context: context, @@ -1047,6 +1120,41 @@ class _SettingsScreenState extends State { ), ]), + _buildSectionHeader('Tracing'), + _buildSettingsCard([ + ListTile( + leading: const Icon(Icons.cloud_sync), + title: const Text('Online trace database'), + subtitle: Text(_onlineTraceCacheSubtitle()), + ), + ListTile( + leading: Icon( + Icons.delete_sweep, + color: _isClearingOnlineTraceCache ? null : Colors.red, + ), + title: Text( + 'Clear online trace database', + style: TextStyle( + color: _isClearingOnlineTraceCache ? null : Colors.red, + ), + ), + subtitle: const Text( + 'Remove the 24-hour cached fallback used when local route matches are incomplete', + ), + enabled: !_isClearingOnlineTraceCache, + trailing: _isClearingOnlineTraceCache + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + onTap: _isClearingOnlineTraceCache + ? null + : _clearOnlineTraceCache, + ), + ]), + _buildSectionHeader('Voice'), Consumer2( builder: (context, appProvider, connectionProvider, child) => diff --git a/lib/services/mesh_map_nodes_service.dart b/lib/services/mesh_map_nodes_service.dart index 0d41ca3..78ae0c7 100644 --- a/lib/services/mesh_map_nodes_service.dart +++ b/lib/services/mesh_map_nodes_service.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; class MeshMapNode { final int type; @@ -33,25 +35,35 @@ class MeshMapNode { class MeshMapNodesService { static const String _nodesEndpoint = 'https://api.meshcore.nz/api/v1/map/nodes'; - static const Duration _cacheTtl = Duration(minutes: 2); - static const Duration traceCacheTtl = Duration(minutes: 10); + static const Duration _cacheTtl = Duration(hours: 24); + static const Duration traceCacheTtl = _cacheTtl; static const Duration traceTimeout = Duration(seconds: 30); + static const String _cacheKey = 'mesh_map_nodes_cache_v1'; + static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1'; static List? _cachedNodes; static DateTime? _cachedAt; + static Future? _ongoingSync; static Future> fetchNodes({ bool forceRefresh = false, Duration cacheTtl = _cacheTtl, + bool allowNetwork = true, + http.Client? client, }) async { final now = DateTime.now(); + final cached = await loadCachedNodes(cacheTtl: cacheTtl); if (!forceRefresh && - _cachedNodes != null && + cached.isNotEmpty && _cachedAt != null && now.difference(_cachedAt!) < cacheTtl) { - return _cachedNodes!; + return cached; } - final response = await http + if (!allowNetwork) { + return cached; + } + + final response = await (client ?? http.Client()) .get(Uri.parse(_nodesEndpoint)) .timeout(traceTimeout); if (response.statusCode < 200 || response.statusCode >= 300) { @@ -69,8 +81,131 @@ class MeshMapNodesService { ) .toList(); - _cachedNodes = nodes; - _cachedAt = now; + await _storeCache(nodes, cachedAt: now); return nodes; } + + static Future> loadCachedNodes({ + Duration cacheTtl = _cacheTtl, + }) async { + final now = DateTime.now(); + if (_cachedNodes != null && + _cachedAt != null && + now.difference(_cachedAt!) < cacheTtl) { + return _cachedNodes!; + } + + final prefs = await SharedPreferences.getInstance(); + final cachedAtMs = prefs.getInt(_cacheTimestampKey); + final cachedJson = prefs.getString(_cacheKey); + if (cachedAtMs == null || cachedJson == null) { + _cachedNodes = null; + _cachedAt = null; + return const []; + } + + final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs); + if (now.difference(cachedAt) >= cacheTtl) { + _cachedNodes = null; + _cachedAt = cachedAt; + return const []; + } + + final decoded = jsonDecode(cachedJson) as List; + final nodes = decoded + .whereType>() + .map(MeshMapNode.fromJson) + .where( + (n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0, + ) + .toList(); + _cachedNodes = nodes; + _cachedAt = cachedAt; + return nodes; + } + + static Future hasFreshCache({Duration cacheTtl = _cacheTtl}) async { + final nodes = await loadCachedNodes(cacheTtl: cacheTtl); + return nodes.isNotEmpty; + } + + static Future syncInBackgroundIfStale({ + Duration cacheTtl = _cacheTtl, + http.Client? client, + }) async { + final now = DateTime.now(); + if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) { + return; + } + + final cached = await loadCachedNodes(cacheTtl: cacheTtl); + if (cached.isNotEmpty && _cachedAt != null) { + return; + } + + if (_ongoingSync != null) { + return _ongoingSync!; + } + + _ongoingSync = () async { + try { + await fetchNodes( + forceRefresh: true, + cacheTtl: cacheTtl, + allowNetwork: true, + client: client, + ); + } catch (_) { + // Background refresh is best-effort only. + } finally { + _ongoingSync = null; + } + }(); + + return _ongoingSync!; + } + + static Future clearCache() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_cacheKey); + await prefs.remove(_cacheTimestampKey); + _cachedNodes = null; + _cachedAt = null; + } + + static Future cachedAt() async { + if (_cachedAt != null) return _cachedAt; + final prefs = await SharedPreferences.getInstance(); + final cachedAtMs = prefs.getInt(_cacheTimestampKey); + if (cachedAtMs == null) return null; + _cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs); + return _cachedAt; + } + + static Future _storeCache( + List nodes, { + required DateTime cachedAt, + }) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _cacheKey, + jsonEncode( + nodes + .map( + (node) => { + 'type': node.type, + 'name': node.name, + 'public_key': node.publicKey, + 'latitude': node.latitude, + 'longitude': node.longitude, + 'updated_at': node.updatedAtMs, + }, + ) + .toList(), + ), + ); + await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch); + _cachedNodes = nodes; + _cachedAt = cachedAt; + } } diff --git a/lib/utils/contact_grouping.dart b/lib/utils/contact_grouping.dart new file mode 100644 index 0000000..d2e110e --- /dev/null +++ b/lib/utils/contact_grouping.dart @@ -0,0 +1,116 @@ +import '../models/contact.dart'; + +class InferredContactGroup { + final String key; + final String label; + final List contacts; + + const InferredContactGroup({ + required this.key, + required this.label, + required this.contacts, + }); + + DateTime get latestSeen => contacts.first.lastSeenTime; +} + +class ContactListItem { + final Contact? contact; + final InferredContactGroup? group; + + const ContactListItem._({this.contact, this.group}); + + const ContactListItem.contact(Contact contact) : this._(contact: contact); + + const ContactListItem.group(InferredContactGroup group) + : this._(group: group); + + bool get isGroup => group != null; + + DateTime get latestSeen => group?.latestSeen ?? contact!.lastSeenTime; +} + +class ContactGrouping { + static final RegExp _prefixedNamePattern = RegExp( + r'^([A-Za-z0-9]{2,})([-_/:])', + ); + + static List sortByLastSeen(List contacts) { + return List.from(contacts) + ..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); + } + + static List buildItems( + List contacts, { + int minGroupSize = 4, + }) { + final sortedContacts = sortByLastSeen(contacts); + return buildItemsFromSorted(sortedContacts, minGroupSize: minGroupSize); + } + + static List buildItemsFromSorted( + List sortedContacts, { + int minGroupSize = 4, + }) { + final groupedContacts = >{}; + final groupLabels = {}; + + for (final contact in sortedContacts) { + final prefix = _extractPrefix(contact.displayName); + if (prefix == null) continue; + groupedContacts.putIfAbsent(prefix.key, () => []).add(contact); + groupLabels.putIfAbsent(prefix.key, () => prefix.label); + } + + final eligibleGroups = {}; + for (final entry in groupedContacts.entries) { + if (entry.value.length < minGroupSize) continue; + eligibleGroups[entry.key] = InferredContactGroup( + key: entry.key, + label: groupLabels[entry.key] ?? entry.key, + contacts: entry.value, + ); + } + + final emittedGroups = {}; + final items = []; + + for (final contact in sortedContacts) { + final prefix = _extractPrefix(contact.displayName); + final group = prefix == null ? null : eligibleGroups[prefix.key]; + if (group == null) { + items.add(ContactListItem.contact(contact)); + continue; + } + if (emittedGroups.add(group.key)) { + items.add(ContactListItem.group(group)); + } + } + + return items; + } + + static _GroupPrefix? _extractPrefix(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return null; + + final match = _prefixedNamePattern.firstMatch(trimmed); + if (match == null) return null; + + final rawPrefix = match.group(1); + final separator = match.group(2); + if (rawPrefix == null || separator == null) return null; + + return _GroupPrefix( + key: rawPrefix.toUpperCase(), + label: '$rawPrefix$separator', + ); + } +} + +class _GroupPrefix { + final String key; + final String label; + + const _GroupPrefix({required this.key, required this.label}); +} diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 4a8750e..e937456 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -49,6 +49,7 @@ class ContactTile extends StatelessWidget { @override Widget build(BuildContext context) { + final isChannel = contact.type == ContactType.channel; final location = contact.displayLocation; // Calculate distance if both positions are available String? distanceText; @@ -115,33 +116,33 @@ class ContactTile extends StatelessWidget { : colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, ); - final subtitleWidget = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (location != null) ...[ - const SizedBox(height: 2), - _buildLocationLine( - context, - latitude: location.latitude, - longitude: location.longitude, - distanceText: distanceText, - ), - if (contact.type != ContactType.channel) ...[ - const SizedBox(height: 6), - Row(children: [_buildRoutePill(context, contact)]), - ], - ] else - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - AppLocalizations.of(context)!.noGpsData, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: Colors.grey), - ), - ), - ], - ); + final Widget? subtitleWidget = isChannel + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (location != null) ...[ + const SizedBox(height: 2), + _buildLocationLine( + context, + latitude: location.latitude, + longitude: location.longitude, + distanceText: distanceText, + ), + const SizedBox(height: 6), + Row(children: [_buildRoutePill(context, contact)]), + ] else + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + AppLocalizations.of(context)!.noGpsData, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.grey), + ), + ), + ], + ); return Container( margin: const EdgeInsets.only(bottom: 8), @@ -238,7 +239,8 @@ class ContactTile extends StatelessWidget { ), ), const SizedBox(width: 8), - Text(timeAgoText, style: timeAgoStyle), + if (!isChannel) + Text(timeAgoText, style: timeAgoStyle), if (isPingInProgress) ...[ const SizedBox(width: 6), SizedBox( @@ -252,7 +254,7 @@ class ContactTile extends StatelessWidget { ], ], ), - subtitleWidget, + ?subtitleWidget, ], ), ), @@ -276,7 +278,8 @@ class ContactTile extends StatelessWidget { contact.type == ContactType.channel; final canSetPath = contact.type == ContactType.chat || contact.type == ContactType.room; - final canAddToSensors = contact.type == ContactType.chat || + final canAddToSensors = + contact.type == ContactType.chat || contact.type == ContactType.repeater; final sensorsProvider = context.read(); final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); @@ -384,7 +387,10 @@ class ContactTile extends StatelessWidget { onNavigateToMap?.call(); } - Future _addContactToSensors(BuildContext context, Contact contact) async { + Future _addContactToSensors( + BuildContext context, + Contact contact, + ) async { await context.read().addSensor(contact); if (!context.mounted) { return; diff --git a/lib/widgets/messages/message_trace_sheet.dart b/lib/widgets/messages/message_trace_sheet.dart index f685a46..fe1ec4a 100644 --- a/lib/widgets/messages/message_trace_sheet.dart +++ b/lib/widgets/messages/message_trace_sheet.dart @@ -1,6 +1,7 @@ // ignore_for_file: use_null_aware_elements import 'dart:math' as math; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:latlong2/latlong.dart'; @@ -60,7 +61,13 @@ class _MessageTraceSheetState extends State { return trace; } - final remoteNodes = await MeshMapNodesService.fetchNodes( + unawaited( + MeshMapNodesService.syncInBackgroundIfStale( + cacheTtl: MeshMapNodesService.traceCacheTtl, + ), + ); + + final remoteNodes = await MeshMapNodesService.loadCachedNodes( cacheTtl: MeshMapNodesService.traceCacheTtl, ); trace = _buildTraceResult( diff --git a/test/services/mesh_map_nodes_service_test.dart b/test/services/mesh_map_nodes_service_test.dart new file mode 100644 index 0000000..f00d9a5 --- /dev/null +++ b/test/services/mesh_map_nodes_service_test.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await MeshMapNodesService.clearCache(); + }); + + test('fetchNodes persists nodes and cached lookup works offline', () async { + final client = MockClient((request) async { + expect( + request.url.toString(), + 'https://api.meshcore.nz/api/v1/map/nodes', + ); + return http.Response( + jsonEncode({ + 'nodes': [ + { + 'type': 1, + 'name': 'Alpha', + 'public_key': 'aa11bb22', + 'latitude': 46.05, + 'longitude': 14.50, + 'updated_at': 123456, + }, + ], + }), + 200, + ); + }); + + final nodes = await MeshMapNodesService.fetchNodes(client: client); + final cached = await MeshMapNodesService.fetchNodes(allowNetwork: false); + + expect(nodes, hasLength(1)); + expect(cached, hasLength(1)); + expect(cached.first.name, 'Alpha'); + expect(await MeshMapNodesService.hasFreshCache(), isTrue); + }); + + test('loadCachedNodes ignores stale persisted cache', () async { + SharedPreferences.setMockInitialValues({ + 'mesh_map_nodes_cache_v1': jsonEncode([ + { + 'type': 1, + 'name': 'Old node', + 'public_key': 'bb22cc33', + 'latitude': 46.05, + 'longitude': 14.50, + 'updated_at': 123456, + }, + ]), + 'mesh_map_nodes_cache_timestamp_v1': DateTime.now() + .subtract(const Duration(hours: 25)) + .millisecondsSinceEpoch, + }); + + final nodes = await MeshMapNodesService.loadCachedNodes(); + + expect(nodes, isEmpty); + expect(await MeshMapNodesService.hasFreshCache(), isFalse); + }); + + test('clearCache removes persisted online trace database', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({ + 'nodes': [ + { + 'type': 1, + 'name': 'Alpha', + 'public_key': 'aa11bb22', + 'latitude': 46.05, + 'longitude': 14.50, + 'updated_at': 123456, + }, + ], + }), + 200, + ), + ); + + await MeshMapNodesService.fetchNodes(client: client); + await MeshMapNodesService.clearCache(); + + expect(await MeshMapNodesService.loadCachedNodes(), isEmpty); + expect(await MeshMapNodesService.cachedAt(), isNull); + }); +} diff --git a/test/utils/contact_grouping_test.dart b/test/utils/contact_grouping_test.dart new file mode 100644 index 0000000..a18f885 --- /dev/null +++ b/test/utils/contact_grouping_test.dart @@ -0,0 +1,120 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/utils/contact_grouping.dart'; + +void main() { + Contact buildContact({ + required int seed, + required String name, + required DateTime lastSeen, + }) { + return Contact( + publicKey: Uint8List.fromList( + List.generate(32, (index) => (seed + index) % 255), + ), + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: lastSeen.millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: lastSeen.millisecondsSinceEpoch ~/ 1000, + ); + } + + group('ContactGrouping.buildItems', () { + test( + 'groups prefixed contacts only when at least four share the prefix', + () { + final now = DateTime(2026, 3, 10, 12); + final items = ContactGrouping.buildItems([ + buildContact(seed: 1, name: 'SI-1', lastSeen: now), + buildContact( + seed: 2, + name: 'SI-2', + lastSeen: now.subtract(const Duration(minutes: 1)), + ), + buildContact( + seed: 3, + name: 'SI-3', + lastSeen: now.subtract(const Duration(minutes: 2)), + ), + buildContact( + seed: 4, + name: 'SI-4', + lastSeen: now.subtract(const Duration(minutes: 3)), + ), + buildContact( + seed: 5, + name: 'OTHER', + lastSeen: now.subtract(const Duration(minutes: 4)), + ), + ]); + + expect(items, hasLength(2)); + expect(items.first.isGroup, isTrue); + expect(items.first.group!.label, 'SI-'); + expect( + items.first.group!.contacts.map((contact) => contact.displayName), + ['SI-1', 'SI-2', 'SI-3', 'SI-4'], + ); + expect(items.last.contact!.displayName, 'OTHER'); + }, + ); + + test('does not group when only three contacts share a prefix', () { + final now = DateTime(2026, 3, 10, 12); + final items = ContactGrouping.buildItems([ + buildContact(seed: 1, name: 'SI-1', lastSeen: now), + buildContact( + seed: 2, + name: 'SI-2', + lastSeen: now.subtract(const Duration(minutes: 1)), + ), + buildContact( + seed: 3, + name: 'SI-3', + lastSeen: now.subtract(const Duration(minutes: 2)), + ), + ]); + + expect(items, hasLength(3)); + expect(items.every((item) => !item.isGroup), isTrue); + }); + + test('orders groups and ungrouped contacts by latest last seen', () { + final now = DateTime(2026, 3, 10, 12); + final items = ContactGrouping.buildItems([ + buildContact( + seed: 1, + name: 'Lone', + lastSeen: now.subtract(const Duration(minutes: 1)), + ), + buildContact(seed: 2, name: 'SI-1', lastSeen: now), + buildContact( + seed: 3, + name: 'SI-2', + lastSeen: now.subtract(const Duration(minutes: 2)), + ), + buildContact( + seed: 4, + name: 'SI-3', + lastSeen: now.subtract(const Duration(minutes: 3)), + ), + buildContact( + seed: 5, + name: 'SI-4', + lastSeen: now.subtract(const Duration(minutes: 4)), + ), + ]); + + expect(items, hasLength(2)); + expect(items.first.isGroup, isTrue); + expect(items.last.contact!.displayName, 'Lone'); + }); + }); +}