From 8085b89c07c79ea560c06de8adff7922e7b24328 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 10 Mar 2026 10:38:59 +0100 Subject: [PATCH] Add repeaters map menu entry --- lib/screens/home_screen.dart | 26 + lib/screens/repeaters_map_screen.dart | 650 ++++++++++++++++++ lib/services/mesh_map_nodes_service.dart | 3 + .../messages_provider_voice_test.dart | 6 +- 4 files changed, 682 insertions(+), 3 deletions(-) create mode 100644 lib/screens/repeaters_map_screen.dart diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 4e19f5d..dfc9b8c 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -14,6 +14,7 @@ import 'messages_tab.dart'; import 'contacts_tab.dart'; import 'sensors_tab.dart'; import 'map_tab.dart'; +import 'repeaters_map_screen.dart'; import 'settings_screen.dart'; import 'device_config_screen.dart'; import 'packet_log_screen.dart'; @@ -646,6 +647,31 @@ class _HomeScreenState extends State ); } + items.add( + PopupMenuItem( + child: const Row( + children: [ + Icon(Icons.router_outlined), + SizedBox(width: 8), + Text('Repeaters Map'), + ], + ), + onTap: () { + final navigator = Navigator.of(context); + Future.delayed(Duration.zero, () { + if (!mounted) return; + navigator.push( + MaterialPageRoute( + builder: (context) => + const RepeatersMapScreen(), + fullscreenDialog: true, + ), + ); + }); + }, + ), + ); + items.add( PopupMenuItem( child: Row( diff --git a/lib/screens/repeaters_map_screen.dart b/lib/screens/repeaters_map_screen.dart new file mode 100644 index 0000000..e6b761a --- /dev/null +++ b/lib/screens/repeaters_map_screen.dart @@ -0,0 +1,650 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../models/contact.dart'; +import '../providers/contacts_provider.dart'; +import '../services/mesh_map_nodes_service.dart'; + +class RepeatersMapScreen extends StatefulWidget { + const RepeatersMapScreen({super.key}); + + @override + State createState() => _RepeatersMapScreenState(); +} + +class _RepeatersMapScreenState extends State { + static const LatLng _fallbackCenter = LatLng(46.0569, 14.5058); + static const double _fallbackZoom = 7; + static const double _myLocationZoom = 14; + + final flutter_map.MapController _mapController = flutter_map.MapController(); + + List _onlineRepeaters = const []; + bool _isLoading = true; + bool _didFitCamera = false; + String? _error; + double _currentZoom = _fallbackZoom; + LatLng? _myLocation; + + @override + void initState() { + super.initState(); + _loadRepeaters(); + } + + Future _loadRepeaters({bool forceRefresh = false}) async { + setState(() { + _isLoading = true; + _error = null; + if (forceRefresh) { + _didFitCamera = false; + } + }); + + try { + final nodes = await MeshMapNodesService.fetchNodes( + forceRefresh: forceRefresh, + ); + final repeaters = nodes.where(MeshMapNodesService.isRepeater).toList(); + + if (!mounted) return; + setState(() { + _onlineRepeaters = repeaters; + _isLoading = false; + }); + _refreshMyLocation(); + } catch (error) { + final cached = await MeshMapNodesService.loadCachedNodes(); + final repeaters = cached.where(MeshMapNodesService.isRepeater).toList(); + + if (!mounted) return; + setState(() { + _onlineRepeaters = repeaters; + _isLoading = false; + _error = repeaters.isEmpty + ? 'Unable to load repeaters right now.' + : 'Showing cached repeaters. Pull to retry.'; + }); + _refreshMyLocation(); + } + } + + List<_MapRepeater> _mergeRepeaters({ + required List onlineNodes, + required List contactRepeaters, + }) { + final merged = {}; + + for (final node in onlineNodes) { + merged[node.publicKey] = _MapRepeater( + publicKey: node.publicKey, + name: node.name, + latitude: node.latitude, + longitude: node.longitude, + isFromContacts: false, + isFromOnline: true, + ); + } + + for (final contact in contactRepeaters) { + final location = contact.displayLocation; + if (location == null) { + continue; + } + + final key = contact.publicKeyHex.toLowerCase(); + final existing = merged[key]; + merged[key] = _MapRepeater( + publicKey: key, + name: contact.displayName.isNotEmpty + ? contact.displayName + : existing?.name ?? 'Unknown repeater', + latitude: existing?.latitude ?? location.latitude, + longitude: existing?.longitude ?? location.longitude, + isFromContacts: true, + isFromOnline: existing?.isFromOnline ?? false, + ); + } + + final repeaters = merged.values.toList() + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return repeaters; + } + + Future _refreshMyLocation() async { + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: 0, + ), + ); + if (!mounted) return; + setState(() { + _myLocation = LatLng(position.latitude, position.longitude); + }); + } catch (_) { + // Best effort only. + } + } + + void _fitCameraIfNeeded(List<_MapRepeater> repeaters) { + if (_didFitCamera || repeaters.isEmpty || !mounted) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _didFitCamera || repeaters.isEmpty) { + return; + } + + if (repeaters.length == 1) { + final repeater = repeaters.first; + _mapController.move(LatLng(repeater.latitude, repeater.longitude), 13); + _currentZoom = 13; + } else { + final bounds = flutter_map.LatLngBounds.fromPoints( + repeaters + .map((node) => LatLng(node.latitude, node.longitude)) + .toList(), + ); + _mapController.fitCamera( + flutter_map.CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(48), + ), + ); + } + + _didFitCamera = true; + }); + } + + void _showRepeaterDetails(_MapRepeater repeater) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + repeater.name, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + _InfoLine( + label: 'Public key', + value: repeater.publicKey, + monospace: true, + ), + _InfoLine( + label: 'Coordinates', + value: + '${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}', + ), + _InfoLine(label: 'Source', value: repeater.sourceLabel), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final contactsProvider = context.watch(); + final repeaters = _mergeRepeaters( + onlineNodes: _onlineRepeaters, + contactRepeaters: contactsProvider.repeaters, + ); + _fitCameraIfNeeded(repeaters); + final clusters = _buildClusters(repeaters); + final markers = clusters.map((cluster) { + final markerColor = switch (cluster.status) { + _ClusterStatus.contactsOnly => const Color(0xFF7C3AED), + _ClusterStatus.onlineOnly => const Color(0xFFE8681D), + _ClusterStatus.both => const Color(0xFF1B8F4F), + _ClusterStatus.mixed => const Color(0xFF2563EB), + }; + + if (cluster.members.length == 1) { + final repeater = cluster.members.first; + return flutter_map.Marker( + point: cluster.center, + width: 40, + height: 40, + child: GestureDetector( + onTap: () => _showRepeaterDetails(repeater), + child: _RepeaterMarker(color: markerColor), + ), + ); + } + + return flutter_map.Marker( + point: cluster.center, + width: 48, + height: 48, + child: GestureDetector( + onTap: () => _zoomToCluster(cluster), + child: _ClusterMarker( + color: markerColor, + count: cluster.members.length, + ), + ), + ); + }).toList(); + final allMarkers = [ + ...markers, + if (_myLocation != null) + flutter_map.Marker( + point: _myLocation!, + width: 24, + height: 24, + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF2563EB), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + ), + ), + ]; + + return Scaffold( + appBar: AppBar( + title: const Text('Repeaters Map'), + actions: [ + IconButton( + onPressed: _isLoading + ? null + : () => _loadRepeaters(forceRefresh: true), + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + ), + ], + ), + body: Stack( + children: [ + flutter_map.FlutterMap( + mapController: _mapController, + options: flutter_map.MapOptions( + initialCenter: _fallbackCenter, + initialZoom: _fallbackZoom, + onPositionChanged: (camera, hasGesture) { + final nextZoom = camera.zoom; + if (nextZoom != _currentZoom && mounted) { + setState(() { + _currentZoom = nextZoom; + }); + } + }, + ), + children: [ + flutter_map.TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'app.meshcore.sar', + ), + flutter_map.MarkerLayer(markers: allMarkers), + ], + ), + if (_error != null) + Positioned( + top: 16, + left: 16, + right: 16, + child: Material( + elevation: 2, + borderRadius: BorderRadius.circular(14), + color: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Text(_error!), + ), + ), + ), + if (_isLoading) const Center(child: CircularProgressIndicator()), + if (!_isLoading && repeaters.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + _error ?? 'No repeaters available.', + textAlign: TextAlign.center, + ), + ), + ), + if (repeaters.isNotEmpty) + Positioned( + left: 16, + top: _error == null ? 16 : 72, + child: Material( + elevation: 2, + borderRadius: BorderRadius.circular(14), + color: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: const [ + _LegendRow( + color: Color(0xFF7C3AED), + label: 'From contacts', + ), + SizedBox(height: 8), + _LegendRow( + color: Color(0xFFE8681D), + label: 'Online only', + ), + SizedBox(height: 8), + _LegendRow(color: Color(0xFF1B8F4F), label: 'In both'), + ], + ), + ), + ), + ), + if (repeaters.isNotEmpty) + Positioned( + left: 16, + right: 16, + bottom: 16, + child: Material( + elevation: 3, + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.surface, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Text( + '${repeaters.length} repeaters', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: _jumpToMyLocation, + tooltip: 'My location', + child: const Icon(Icons.my_location), + ), + ); + } + + Future _jumpToMyLocation() async { + await _refreshMyLocation(); + final myLocation = _myLocation; + if (!mounted || myLocation == null) { + return; + } + + _mapController.move(myLocation, _myLocationZoom); + setState(() { + _currentZoom = _myLocationZoom; + }); + } + + List<_RepeaterCluster> _buildClusters(List<_MapRepeater> repeaters) { + if (repeaters.isEmpty) { + return const []; + } + + if (_currentZoom >= 11.5) { + return repeaters + .map( + (repeater) => _RepeaterCluster( + members: [repeater], + center: LatLng(repeater.latitude, repeater.longitude), + status: repeater.clusterStatus, + ), + ) + .toList(); + } + + final cellSize = _gridSizeForZoom(_currentZoom); + final buckets = >{}; + + for (final repeater in repeaters) { + final latBucket = (repeater.latitude / cellSize).floor(); + final lonBucket = (repeater.longitude / cellSize).floor(); + final key = '$latBucket:$lonBucket'; + buckets.putIfAbsent(key, () => <_MapRepeater>[]).add(repeater); + } + + return buckets.values.map((members) { + final centerLat = + members.map((node) => node.latitude).reduce((a, b) => a + b) / + members.length; + final centerLon = + members.map((node) => node.longitude).reduce((a, b) => a + b) / + members.length; + final statuses = members.map((node) => node.clusterStatus).toSet(); + return _RepeaterCluster( + members: members, + center: LatLng(centerLat, centerLon), + status: statuses.length == 1 ? statuses.first : _ClusterStatus.mixed, + ); + }).toList(); + } + + double _gridSizeForZoom(double zoom) { + if (zoom < 5) return 3.0; + if (zoom < 7) return 1.5; + if (zoom < 9) return 0.7; + if (zoom < 10.5) return 0.28; + return 0.12; + } + + void _zoomToCluster(_RepeaterCluster cluster) { + if (cluster.members.isEmpty) { + return; + } + + final bounds = flutter_map.LatLngBounds.fromPoints( + cluster.members + .map((node) => LatLng(node.latitude, node.longitude)) + .toList(), + ); + _mapController.fitCamera( + flutter_map.CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(64), + maxZoom: (_currentZoom + 2).clamp(8, 14), + ), + ); + } +} + +class _InfoLine extends StatelessWidget { + final String label; + final String value; + final bool monospace; + + const _InfoLine({ + required this.label, + required this.value, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 92, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ), + Expanded( + child: Text( + value, + style: monospace + ? const TextStyle(fontFamily: 'monospace') + : null, + ), + ), + ], + ), + ); + } +} + +class _LegendRow extends StatelessWidget { + final Color color; + final String label; + + const _LegendRow({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text(label), + ], + ); + } +} + +class _RepeaterMarker extends StatelessWidget { + final Color color; + + const _RepeaterMarker({required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: const Icon(Icons.router, color: Colors.white, size: 18), + ); + } +} + +class _ClusterMarker extends StatelessWidget { + final Color color; + final int count; + + const _ClusterMarker({required this.color, required this.count}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.22), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + alignment: Alignment.center, + child: Text( + '$count', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 14, + ), + ), + ); + } +} + +enum _ClusterStatus { contactsOnly, onlineOnly, both, mixed } + +class _RepeaterCluster { + final List<_MapRepeater> members; + final LatLng center; + final _ClusterStatus status; + + const _RepeaterCluster({ + required this.members, + required this.center, + required this.status, + }); +} + +class _MapRepeater { + final String publicKey; + final String name; + final double latitude; + final double longitude; + final bool isFromContacts; + final bool isFromOnline; + + const _MapRepeater({ + required this.publicKey, + required this.name, + required this.latitude, + required this.longitude, + required this.isFromContacts, + required this.isFromOnline, + }); + + String get sourceLabel { + if (isFromContacts && isFromOnline) return 'Contacts and online'; + if (isFromContacts) return 'Contacts'; + return 'Online'; + } + + _ClusterStatus get clusterStatus { + if (isFromContacts && isFromOnline) return _ClusterStatus.both; + if (isFromContacts) return _ClusterStatus.contactsOnly; + return _ClusterStatus.onlineOnly; + } +} diff --git a/lib/services/mesh_map_nodes_service.dart b/lib/services/mesh_map_nodes_service.dart index 78ae0c7..a48411a 100644 --- a/lib/services/mesh_map_nodes_service.dart +++ b/lib/services/mesh_map_nodes_service.dart @@ -35,6 +35,7 @@ class MeshMapNode { class MeshMapNodesService { static const String _nodesEndpoint = 'https://api.meshcore.nz/api/v1/map/nodes'; + static const int repeaterType = 1; static const Duration _cacheTtl = Duration(hours: 24); static const Duration traceCacheTtl = _cacheTtl; static const Duration traceTimeout = Duration(seconds: 30); @@ -182,6 +183,8 @@ class MeshMapNodesService { return _cachedAt; } + static bool isRepeater(MeshMapNode node) => node.type == repeaterType; + static Future _storeCache( List nodes, { required DateTime cachedAt, diff --git a/test/providers/messages_provider_voice_test.dart b/test/providers/messages_provider_voice_test.dart index 9d28520..b8715b2 100644 --- a/test/providers/messages_provider_voice_test.dart +++ b/test/providers/messages_provider_voice_test.dart @@ -12,7 +12,7 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - Contact _buildContact({ + Contact buildContact({ required List prefix, required ContactType type, String name = 'Test Contact', @@ -218,12 +218,12 @@ void main() { test('marks only the selected contact destination as read', () { final provider = MessagesProvider(); - final alice = _buildContact( + final alice = buildContact( prefix: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15], type: ContactType.chat, name: 'Alice', ); - final bob = _buildContact( + final bob = buildContact( prefix: [0x20, 0x21, 0x22, 0x23, 0x24, 0x25], type: ContactType.chat, name: 'Bob',