From 820bac0db049ccedd91aae909527be161b5a5f9e Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Tue, 21 Apr 2026 16:44:04 -0700 Subject: [PATCH 1/5] fix issues with contact sync this adds the actual last modified timestamp when present, before we used last advert time as last modified in error also sets _pendingInitialContactsSync to true on first connect over BLE --- lib/connector/meshcore_connector.dart | 5 +++- lib/connector/meshcore_protocol.dart | 13 +++++++--- lib/models/contact.dart | 31 +++++++++++++++++++++--- lib/storage/contact_discovery_store.dart | 5 ++++ lib/storage/contact_store.dart | 5 ++++ 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index fceee150..be0d8935 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -2140,6 +2140,7 @@ class MeshCoreConnector extends ChangeNotifier { return; } _bleInitialSyncStarted = true; + _pendingInitialContactsSync = true; await _requestDeviceInfo(); _startBatteryPolling(); @@ -4117,7 +4118,9 @@ class MeshCoreConnector extends ChangeNotifier { if (_contacts.isEmpty) return 0; var latest = 0; for (final contact in _contacts) { - final seconds = contact.lastSeen.millisecondsSinceEpoch ~/ 1000; + // prefer lastmod per spec, fallback to lastseen + final source = contact.lastModified ?? contact.lastSeen; + final seconds = source.millisecondsSinceEpoch ~/ 1000; if (seconds > latest) { latest = seconds; } diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 396d78b3..dcb6948e 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -735,10 +735,15 @@ Uint8List buildUpdateContactPathFrame( writer.writeInt32LE((longitude * 1e6).round()); } - if (lastModified != null) { - // Last modified - final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000; - writer.writeUInt32LE(lastModifiedTimestamp); + final hasLocation = lat != null && lon != null; + if (hasLocation || lastModified != null) { + writer.writeInt32LE(hasLocation ? (lat * 1e6).round() : 0); + writer.writeInt32LE(hasLocation ? (lon * 1e6).round() : 0); + if (lastModified != null) { + // Last modified + final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000; + writer.writeUInt32LE(lastModifiedTimestamp); + } } return writer.toBytes(); diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 2699f939..a126ade8 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -17,6 +17,7 @@ class Contact { final double? longitude; final DateTime lastSeen; final DateTime lastMessageAt; + final DateTime? lastModified; final bool isActive; final bool wasPulled; final Uint8List? rawPacket; @@ -33,6 +34,7 @@ class Contact { this.latitude, this.longitude, required this.lastSeen, + this.lastModified, DateTime? lastMessageAt, this.isActive = true, this.wasPulled = false, @@ -94,6 +96,7 @@ class Contact { double? longitude, DateTime? lastSeen, DateTime? lastMessageAt, + DateTime? lastModified, bool? isActive, Uint8List? rawPacket, }) { @@ -114,6 +117,7 @@ class Contact { longitude: longitude ?? this.longitude, lastSeen: lastSeen ?? this.lastSeen, lastMessageAt: lastMessageAt ?? this.lastMessageAt, + lastModified: lastModified ?? this.lastModified, isActive: isActive ?? this.isActive, rawPacket: rawPacket ?? this.rawPacket, ); @@ -182,16 +186,34 @@ class Contact { return null; } - final lastMod = reader.readUInt32LE(); + // mandatory last_advert_timestamp + final lastAdvertTimestamp = reader.readUInt32LE(); double? lat, lon; - if (reader.remaining >= 8) { + DateTime? lastModified; + if (reader.remaining >= 12) { + final latRaw = reader.readInt32LE(); + final lonRaw = reader.readInt32LE(); + final lastModRaw = reader.readUInt32LE(); + // TODO: should this be &&? + if (latRaw != 0 || lonRaw != 0) { + lat = latRaw / 1e6; + lon = lonRaw / 1e6; + } + if (lastModRaw != 0) { + lastModified = DateTime.fromMillisecondsSinceEpoch(lastModRaw * 1000); + } + } else if (reader.remaining >= 8) { + // Old layout: gps without lastmod final latRaw = reader.readInt32LE(); final lonRaw = reader.readInt32LE(); if (latRaw != 0 || lonRaw != 0) { lat = latRaw / 1e6; lon = lonRaw / 1e6; } + appLogger.info( + 'Contact ${pubKeyToHex(pubKey).substring(0, 8)} has gps but no lastmod (legacy firmware layout)', + ); } return Contact( @@ -203,7 +225,10 @@ class Contact { path: pathBytes, latitude: lat, longitude: lon, - lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000), + lastSeen: DateTime.fromMillisecondsSinceEpoch( + lastAdvertTimestamp * 1000, + ), + lastModified: lastModified, isActive: true, rawPacket: null, ); diff --git a/lib/storage/contact_discovery_store.dart b/lib/storage/contact_discovery_store.dart index 89ca0273..3f6f1718 100644 --- a/lib/storage/contact_discovery_store.dart +++ b/lib/storage/contact_discovery_store.dart @@ -43,6 +43,7 @@ class ContactDiscoveryStore { 'latitude': contact.latitude, 'longitude': contact.longitude, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch, + 'lastModified': contact.lastModified?.millisecondsSinceEpoch, 'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch, 'rawPacket': contact.rawPacket != null ? base64Encode(contact.rawPacket!) @@ -53,6 +54,7 @@ class ContactDiscoveryStore { Contact _fromJson(Map json) { final lastSeenMs = json['lastSeen'] as int? ?? 0; final lastMessageMs = json['lastMessageAt'] as int?; + final lastModifiedMs = json['lastModified'] as int?; return Contact( publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), name: json['name'] as String? ?? 'Unknown', @@ -71,6 +73,9 @@ class ContactDiscoveryStore { latitude: (json['latitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), + lastModified: lastModifiedMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastModifiedMs), lastMessageAt: DateTime.fromMillisecondsSinceEpoch( lastMessageMs ?? lastSeenMs, ), diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index 0e2e3ad1..7883d885 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -75,6 +75,7 @@ class ContactStore { 'latitude': contact.latitude, 'longitude': contact.longitude, 'lastSeen': contact.lastSeen.millisecondsSinceEpoch, + 'lastModified': contact.lastModified?.millisecondsSinceEpoch, 'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch, 'isActive': contact.isActive, 'rawPacket': contact.rawPacket != null @@ -86,6 +87,7 @@ class ContactStore { Contact _fromJson(Map json) { final lastSeenMs = json['lastSeen'] as int? ?? 0; final lastMessageMs = json['lastMessageAt'] as int?; + final lastModifiedMs = json['lastModified'] as int?; return Contact( publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), name: json['name'] as String? ?? 'Unknown', @@ -104,6 +106,9 @@ class ContactStore { latitude: (json['latitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), + lastModified: lastModifiedMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastModifiedMs), lastMessageAt: DateTime.fromMillisecondsSinceEpoch( lastMessageMs ?? lastSeenMs, ), From eb50249b93e0499447a8cab3761106aa70325f07 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Tue, 28 Apr 2026 19:26:51 -0700 Subject: [PATCH 2/5] Add desktop map controls and improve zoom functionality across multiple screens --- lib/screens/channel_message_path_screen.dart | 101 ++++++++++++++++++- lib/screens/channels_screen.dart | 9 +- lib/screens/chat_screen.dart | 10 +- lib/screens/contacts_screen.dart | 21 ++-- lib/screens/line_of_sight_map_screen.dart | 99 ++++++++++++++++++ lib/screens/map_cache_screen.dart | 82 ++++++++++++++- lib/screens/map_screen.dart | 85 +++++++++++++++- lib/screens/path_trace_map.dart | 88 +++++++++++++++- lib/services/background_service.dart | 3 +- test/l10n/contact_localization_test.dart | 20 +--- 10 files changed, 468 insertions(+), 50 deletions(-) diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 53769d40..afb2b906 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -304,6 +304,8 @@ class ChannelMessagePathMapScreen extends StatefulWidget { class _ChannelMessagePathMapScreenState extends State { static const double _labelZoomThreshold = 8.5; + static const double _mapMinZoom = 2.0; + static const double _mapMaxZoom = 18.0; final MapController _mapController = MapController(); Uint8List? _selectedPath; @@ -330,6 +332,18 @@ class _ChannelMessagePathMapScreenState } } + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } + + bool _isDesktopPlatform(TargetPlatform platform) { + return platform == TargetPlatform.linux || + platform == TargetPlatform.windows || + platform == TargetPlatform.macOS; + } + double _getPathDistance(List points) { double totalDistance = 0.0; final distanceCalculator = Distance(); @@ -357,6 +371,70 @@ class _ChannelMessagePathMapScreenState }); } + void _zoomMapBy(double delta) { + final camera = _mapController.camera; + final nextZoom = (camera.zoom + delta) + .clamp(_mapMinZoom, _mapMaxZoom) + .toDouble(); + _mapController.move(camera.center, nextZoom); + } + + void _resetMapView({ + required LatLng initialCenter, + required double initialZoom, + required LatLngBounds? bounds, + }) { + if (bounds != null) { + _mapController.fitCamera( + CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(64), + maxZoom: 16, + ), + ); + return; + } + _mapController.move(initialCenter, initialZoom); + } + + Widget _buildDesktopMapControls({ + required LatLng initialCenter, + required double initialZoom, + required LatLngBounds? bounds, + }) { + return Positioned( + left: 16, + top: 16, + child: Card( + elevation: 4, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Zoom in', + onPressed: () => _zoomMapBy(1), + ), + IconButton( + icon: const Icon(Icons.remove), + tooltip: 'Zoom out', + onPressed: () => _zoomMapBy(-1), + ), + IconButton( + icon: const Icon(Icons.my_location), + tooltip: 'Center map', + onPressed: () => _resetMapView( + initialCenter: initialCenter, + initialZoom: initialZoom, + bounds: bounds, + ), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return Consumer( @@ -372,6 +450,7 @@ class _ChannelMessagePathMapScreenState primaryPath, widget.message.pathVariants, ); + final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final selectedPathTmp = _resolveSelectedPath( _selectedPath, observedPaths, @@ -451,10 +530,20 @@ class _ChannelMessagePathMapScreenState padding: const EdgeInsets.all(64), maxZoom: 16, ), - minZoom: 2.0, - maxZoom: 18.0, + minZoom: _mapMinZoom, + maxZoom: _mapMaxZoom, interactionOptions: InteractionOptions( flags: ~InteractiveFlag.rotate, + scrollWheelVelocity: isDesktop ? 0.012 : 0.005, + cursorKeyboardRotationOptions: + CursorKeyboardRotationOptions.disabled(), + keyboardOptions: isDesktop + ? const KeyboardOptions( + enableArrowKeysPanning: true, + enableWASDPanning: true, + enableRFZooming: true, + ) + : const KeyboardOptions.disabled(), ), onPositionChanged: (camera, hasGesture) { final shouldShow = camera.zoom >= _labelZoomThreshold; @@ -486,6 +575,12 @@ class _ChannelMessagePathMapScreenState ), ], ), + if (isDesktop) + _buildDesktopMapControls( + initialCenter: initialCenter, + initialZoom: initialZoom, + bounds: bounds, + ), if (observedPaths.length > 1) _buildPathSelector(context, observedPaths, selectedIndex, ( index, diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 59dcea1e..5db12852 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -492,8 +492,9 @@ class _ChannelsScreenState extends State ], ), onTap: () async { - final unread = - connector.getUnreadCountForChannelIndex(channel.index); + final unread = connector.getUnreadCountForChannelIndex( + channel.index, + ); connector.markChannelRead(channel.index); await Future.delayed(const Duration(milliseconds: 50)); if (context.mounted) { @@ -1497,7 +1498,9 @@ class _ChannelsScreenState extends State if (!context.mounted) return; showDismissibleSnackBar( context, - content: Text(context.l10n.channels_channelUpdateFailed('$e')), + content: Text( + context.l10n.channels_channelUpdateFailed('$e'), + ), ); } }, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 2a80b341..54e1ecbe 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1218,8 +1218,14 @@ class _ChatScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildInfoRow(context.l10n.chat_type, contact.typeLabel(context.l10n)), - _buildInfoRow(context.l10n.chat_path, contact.pathLabel(context.l10n)), + _buildInfoRow( + context.l10n.chat_type, + contact.typeLabel(context.l10n), + ), + _buildInfoRow( + context.l10n.chat_path, + contact.pathLabel(context.l10n), + ), _buildInfoRow( context.l10n.contact_lastSeen, _formatContactLastMessage(contact.lastMessageAt), diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index f2bd7374..a4cc35ca 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -932,16 +932,15 @@ class _ContactsScreenState extends State _showRoomLogin(context, contact, RoomLoginDestination.chat); } else { final connector = context.read(); - final unread = - connector.getUnreadCountForContactKey(contact.publicKeyHex); + final unread = connector.getUnreadCountForContactKey( + contact.publicKeyHex, + ); connector.markContactRead(contact.publicKeyHex); Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen( - contact: contact, - initialUnreadCount: unread, - ), + builder: (context) => + ChatScreen(contact: contact, initialUnreadCount: unread), ), ); } @@ -998,8 +997,9 @@ class _ContactsScreenState extends State room: room, onLogin: (password, isAdmin) { final connector = context.read(); - final unread = - connector.getUnreadCountForContactKey(room.publicKeyHex); + final unread = connector.getUnreadCountForContactKey( + room.publicKeyHex, + ); connector.markContactRead(room.publicKeyHex); Navigator.push( context, @@ -1011,10 +1011,7 @@ class _ContactsScreenState extends State password: password, isAdmin: isAdmin, ) - : ChatScreen( - contact: room, - initialUnreadCount: unread, - ), + : ChatScreen(contact: room, initialUnreadCount: unread), ), ); }, diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index e88f4b9e..3ba79d59 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -56,8 +57,11 @@ class _LineOfSightMapScreenState extends State { static const double _maxAntennaFeet = 400.0; static const double _maxAntennaMeters = _maxAntennaFeet / _metersToFeet; static const double _labelZoomThreshold = 8.5; + static const double _mapMinZoom = 2.0; + static const double _mapMaxZoom = 18.0; final LineOfSightService _lineOfSightService = LineOfSightService(); + final MapController _mapController = MapController(); bool _loading = false; String? _error; @@ -99,10 +103,85 @@ class _LineOfSightMapScreenState extends State { @override void dispose() { + _mapController.dispose(); _lineOfSightService.dispose(); super.dispose(); } + bool _isDesktopPlatform(TargetPlatform platform) { + return platform == TargetPlatform.linux || + platform == TargetPlatform.windows || + platform == TargetPlatform.macOS; + } + + void _zoomMapBy(double delta) { + final camera = _mapController.camera; + final nextZoom = (camera.zoom + delta) + .clamp(_mapMinZoom, _mapMaxZoom) + .toDouble(); + _mapController.move(camera.center, nextZoom); + } + + void _resetMapView({ + required LatLng initialCenter, + required double initialZoom, + required LatLngBounds? bounds, + }) { + if (bounds != null) { + _mapController.fitCamera( + CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(64), + maxZoom: 16, + ), + ); + return; + } + _mapController.move(initialCenter, initialZoom); + } + + Widget _buildDesktopMapControls({ + required LatLng initialCenter, + required double initialZoom, + required LatLngBounds? bounds, + }) { + final screenHeight = MediaQuery.of(context).size.height; + final topOffset = _showHud + ? math.min(screenHeight * 0.52 + 24, screenHeight - 220) + : 12.0; + return Positioned( + top: topOffset, + left: 12, + child: Card( + elevation: 4, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Zoom in', + onPressed: () => _zoomMapBy(1), + ), + IconButton( + icon: const Icon(Icons.remove), + tooltip: 'Zoom out', + onPressed: () => _zoomMapBy(-1), + ), + IconButton( + icon: const Icon(Icons.my_location), + tooltip: 'Center map', + onPressed: () => _resetMapView( + initialCenter: initialCenter, + initialZoom: initialZoom, + bounds: bounds, + ), + ), + ], + ), + ), + ); + } + Future _runLos() async { final start = _start; final end = _end; @@ -325,6 +404,7 @@ class _LineOfSightMapScreenState extends State { ? LatLngBounds.fromPoints(mapPoints) : null; final initialZoom = mapPoints.length > 1 ? 13.0 : 2.0; + final isDesktop = _isDesktopPlatform(defaultTargetPlatform); if (!_didReceivePositionUpdate) { _showMarkerLabels = initialZoom >= _labelZoomThreshold; } @@ -350,6 +430,7 @@ class _LineOfSightMapScreenState extends State { body: Stack( children: [ FlutterMap( + mapController: _mapController, options: MapOptions( initialCenter: initialCenter, initialZoom: initialZoom, @@ -362,7 +443,19 @@ class _LineOfSightMapScreenState extends State { ), interactionOptions: InteractionOptions( flags: ~InteractiveFlag.rotate, + scrollWheelVelocity: isDesktop ? 0.012 : 0.005, + cursorKeyboardRotationOptions: + CursorKeyboardRotationOptions.disabled(), + keyboardOptions: isDesktop + ? const KeyboardOptions( + enableArrowKeysPanning: true, + enableWASDPanning: true, + enableRFZooming: true, + ) + : const KeyboardOptions.disabled(), ), + minZoom: _mapMinZoom, + maxZoom: _mapMaxZoom, onLongPress: (_, point) => _addCustomPoint(point), onPositionChanged: (camera, hasGesture) { final shouldShow = camera.zoom >= _labelZoomThreshold; @@ -389,6 +482,12 @@ class _LineOfSightMapScreenState extends State { ), ], ), + if (isDesktop) + _buildDesktopMapControls( + initialCenter: initialCenter, + initialZoom: initialZoom, + bounds: bounds, + ), if (_showHud) Positioned( left: 12, diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart index 1eb59a86..4057e0ec 100644 --- a/lib/screens/map_cache_screen.dart +++ b/lib/screens/map_cache_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -18,6 +19,9 @@ class MapCacheScreen extends StatefulWidget { } class _MapCacheScreenState extends State { + static const double _mapMinZoom = 2.0; + static const double _mapMaxZoom = 18.0; + final MapController _mapController = MapController(); LatLngBounds? _selectedBounds; @@ -43,6 +47,61 @@ class _MapCacheScreenState extends State { super.dispose(); } + bool _isDesktopPlatform(TargetPlatform platform) { + return platform == TargetPlatform.linux || + platform == TargetPlatform.windows || + platform == TargetPlatform.macOS; + } + + void _zoomMapBy(double delta) { + final camera = _mapController.camera; + final nextZoom = (camera.zoom + delta) + .clamp(_mapMinZoom, _mapMaxZoom) + .toDouble(); + _mapController.move(camera.center, nextZoom); + } + + void _resetMapView() { + final bounds = _selectedBounds; + if (bounds != null) { + _mapController.fitCamera( + CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)), + ); + return; + } + _mapController.move(const LatLng(0, 0), 2.0); + } + + Widget _buildDesktopMapControls() { + return Positioned( + top: 12, + left: 12, + child: Card( + elevation: 4, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Zoom in', + onPressed: () => _zoomMapBy(1), + ), + IconButton( + icon: const Icon(Icons.remove), + tooltip: 'Zoom out', + onPressed: () => _zoomMapBy(-1), + ), + IconButton( + icon: const Icon(Icons.my_location), + tooltip: 'Center map', + onPressed: _resetMapView, + ), + ], + ), + ), + ); + } + void _loadSettings() { final settings = context.read().settings; final bounds = MapTileCacheService.boundsFromJson(settings.mapCacheBounds); @@ -222,6 +281,7 @@ class _MapCacheScreenState extends State { final tileCache = context.read(); final selectedBounds = _selectedBounds; final l10n = context.l10n; + final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final progressValue = _estimatedTiles == 0 ? 0.0 : (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble(); @@ -238,11 +298,24 @@ class _MapCacheScreenState extends State { children: [ FlutterMap( mapController: _mapController, - options: const MapOptions( - initialCenter: LatLng(0, 0), + options: MapOptions( + initialCenter: const LatLng(0, 0), initialZoom: 2.0, - minZoom: 2.0, - maxZoom: 18.0, + minZoom: _mapMinZoom, + maxZoom: _mapMaxZoom, + interactionOptions: InteractionOptions( + flags: ~InteractiveFlag.rotate, + scrollWheelVelocity: isDesktop ? 0.012 : 0.005, + cursorKeyboardRotationOptions: + CursorKeyboardRotationOptions.disabled(), + keyboardOptions: isDesktop + ? const KeyboardOptions( + enableArrowKeysPanning: true, + enableWASDPanning: true, + enableRFZooming: true, + ) + : const KeyboardOptions.disabled(), + ), ), children: [ TileLayer( @@ -265,6 +338,7 @@ class _MapCacheScreenState extends State { ), ], ), + if (isDesktop) _buildDesktopMapControls(), Positioned( top: 12, right: 12, diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index efc1656f..9108e2b4 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1,6 +1,5 @@ import 'dart:collection'; import 'dart:math'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -58,6 +57,8 @@ class MapScreen extends StatefulWidget { class _MapScreenState extends State { // Zoom level at which node labels start to appear static const double _labelZoomThreshold = 14.0; + static const double _mapMinZoom = 2.0; + static const double _mapMaxZoom = 18.0; final MapController _mapController = MapController(); final MapMarkerService _markerService = MapMarkerService(); @@ -150,11 +151,62 @@ class _MapScreenState extends State { return zoom.clamp(4.0, 15.0); } + bool _isDesktopPlatform(TargetPlatform platform) { + return platform == TargetPlatform.linux || + platform == TargetPlatform.windows || + platform == TargetPlatform.macOS; + } + + void _zoomMapBy(double delta) { + final camera = _mapController.camera; + final nextZoom = (camera.zoom + delta) + .clamp(_mapMinZoom, _mapMaxZoom) + .toDouble(); + _mapController.move(camera.center, nextZoom); + } + + Widget _buildDesktopMapControls( + BuildContext context, { + required LatLng center, + required double zoom, + required bool hasPathSelector, + }) { + return Positioned( + left: 16, + top: hasPathSelector ? null : 16, + bottom: hasPathSelector ? 16 : null, + child: Card( + elevation: 4, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Zoom in', + onPressed: () => _zoomMapBy(1), + ), + IconButton( + icon: const Icon(Icons.remove), + tooltip: 'Zoom out', + onPressed: () => _zoomMapBy(-1), + ), + IconButton( + icon: const Icon(Icons.my_location), + tooltip: 'Center map', + onPressed: () => _mapController.move(center, zoom), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return Consumer3( builder: (context, connector, settingsService, pathHistory, child) { final tileCache = context.read(); + final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final settings = settingsService.settings; final allContacts = connector.allContacts; @@ -451,10 +503,20 @@ class _MapScreenState extends State { options: MapOptions( initialCenter: center, initialZoom: initialZoom, - minZoom: 2.0, - maxZoom: 18.0, + minZoom: _mapMinZoom, + maxZoom: _mapMaxZoom, interactionOptions: InteractionOptions( flags: ~InteractiveFlag.rotate, + scrollWheelVelocity: isDesktop ? 0.012 : 0.005, + cursorKeyboardRotationOptions: + CursorKeyboardRotationOptions.disabled(), + keyboardOptions: isDesktop + ? const KeyboardOptions( + enableArrowKeysPanning: true, + enableWASDPanning: true, + enableRFZooming: true, + ) + : const KeyboardOptions.disabled(), ), onTap: (_, latLng) { if (_isSelectingPoi) { @@ -598,6 +660,13 @@ class _MapScreenState extends State { sharedMarkers.length, guessedLocations.length, ), + if (isDesktop) + _buildDesktopMapControls( + context, + center: center, + zoom: initialZoom, + hasPathSelector: _isBuildingPathTrace, + ), if (_isBuildingPathTrace) _buildPathTraceOverlay(), ], ), @@ -1480,8 +1549,14 @@ class _MapScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildInfoRow(context.l10n.map_type, contact.typeLabel(context.l10n)), - _buildInfoRow(context.l10n.map_path, contact.pathLabel(context.l10n)), + _buildInfoRow( + context.l10n.map_type, + contact.typeLabel(context.l10n), + ), + _buildInfoRow( + context.l10n.map_path, + contact.pathLabel(context.l10n), + ), if (contact.hasLocation) _buildInfoRow( context.l10n.map_location, diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 7f3b4eb5..c810570d 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -76,9 +76,12 @@ class PathTraceMapScreen extends StatefulWidget { class _PathTraceMapScreenState extends State { static const double _labelZoomThreshold = 8.5; + static const double _mapMinZoom = 2.0; + static const double _mapMaxZoom = 18.0; //miles to meters conversion for filtering out repeaters that are too far from the last known GPS hop to be a likely match, to avoid false matches that throw off the inferred positions of other hops in the path static const double _maxRepeaterMatchDistanceMeters = 40 * 1609.344; + final MapController _mapController = MapController(); StreamSubscription? _frameSubscription; Timer? _timeoutTimer; @@ -116,11 +119,74 @@ class _PathTraceMapScreenState extends State { @override void dispose() { + _mapController.dispose(); _frameSubscription?.cancel(); _timeoutTimer?.cancel(); super.dispose(); } + bool _isDesktopPlatform(TargetPlatform platform) { + return platform == TargetPlatform.linux || + platform == TargetPlatform.windows || + platform == TargetPlatform.macOS; + } + + void _zoomMapBy(double delta) { + final camera = _mapController.camera; + final nextZoom = (camera.zoom + delta) + .clamp(_mapMinZoom, _mapMaxZoom) + .toDouble(); + _mapController.move(camera.center, nextZoom); + } + + void _resetMapView() { + final bounds = _bounds; + if (bounds != null) { + _mapController.fitCamera( + CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(64), + maxZoom: 16, + ), + ); + return; + } + final center = _initialCenter; + if (center != null) { + _mapController.move(center, _initialZoom); + } + } + + Widget _buildDesktopMapControls() { + return Positioned( + top: 16, + left: 16, + child: Card( + elevation: 4, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Zoom in', + onPressed: () => _zoomMapBy(1), + ), + IconButton( + icon: const Icon(Icons.remove), + tooltip: 'Zoom out', + onPressed: () => _zoomMapBy(-1), + ), + IconButton( + icon: const Icon(Icons.my_location), + tooltip: 'Center map', + onPressed: _resetMapView, + ), + ], + ), + ), + ); + } + Uint8List buildPath(Uint8List pathBytes) { Uint8List traceBytes; @@ -519,6 +585,8 @@ class _PathTraceMapScreenState extends State { ), if (_hasData) _buildMapPathTrace(context, tileCache, _targetContact), + if (_hasData && _isDesktopPlatform(defaultTargetPlatform)) + _buildDesktopMapControls(), if (_points.isEmpty && !_hasData && !_isLoading && @@ -801,10 +869,24 @@ class _PathTraceMapScreenState extends State { MapTileCacheService tileCache, Contact? target, ) { + final isDesktop = _isDesktopPlatform(defaultTargetPlatform); return FlutterMap( key: _mapKey, + mapController: _mapController, options: MapOptions( - interactionOptions: InteractionOptions(flags: ~InteractiveFlag.rotate), + interactionOptions: InteractionOptions( + flags: ~InteractiveFlag.rotate, + scrollWheelVelocity: isDesktop ? 0.012 : 0.005, + cursorKeyboardRotationOptions: + CursorKeyboardRotationOptions.disabled(), + keyboardOptions: isDesktop + ? const KeyboardOptions( + enableArrowKeysPanning: true, + enableWASDPanning: true, + enableRFZooming: true, + ) + : const KeyboardOptions.disabled(), + ), initialCenter: _initialCenter!, initialZoom: _initialZoom, initialCameraFit: _bounds == null @@ -814,8 +896,8 @@ class _PathTraceMapScreenState extends State { padding: const EdgeInsets.all(64), maxZoom: 16, ), - minZoom: 2.0, - maxZoom: 18.0, + minZoom: _mapMinZoom, + maxZoom: _mapMaxZoom, onPositionChanged: (camera, hasGesture) { final shouldShow = camera.zoom >= _labelZoomThreshold; if (shouldShow != _showNodeLabels && mounted) { diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 336ecdd4..23d5fafe 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -65,8 +65,7 @@ class BackgroundService { return AppLocalizations.delegate.load(overrideLocale); } } - final preferred = - WidgetsBinding.instance.platformDispatcher.locales; + final preferred = WidgetsBinding.instance.platformDispatcher.locales; final match = basicLocaleListResolution(preferred, supported); return AppLocalizations.delegate.load(match); } diff --git a/test/l10n/contact_localization_test.dart b/test/l10n/contact_localization_test.dart index 87df23a7..62f8b6c8 100644 --- a/test/l10n/contact_localization_test.dart +++ b/test/l10n/contact_localization_test.dart @@ -36,10 +36,7 @@ void main() { }); test('repeater', () { - expect( - _contact(type: advTypeRepeater).typeLabel(l10n), - 'Repeater', - ); + expect(_contact(type: advTypeRepeater).typeLabel(l10n), 'Repeater'); }); test('room', () { @@ -57,24 +54,15 @@ void main() { group('Contact.pathLabel (override)', () { test('override < 0 -> flood (forced)', () { - expect( - _contact(pathOverride: -1).pathLabel(l10n), - 'Flood (forced)', - ); + expect(_contact(pathOverride: -1).pathLabel(l10n), 'Flood (forced)'); }); test('override == 0 -> direct (forced)', () { - expect( - _contact(pathOverride: 0).pathLabel(l10n), - 'Direct (forced)', - ); + expect(_contact(pathOverride: 0).pathLabel(l10n), 'Direct (forced)'); }); test('override > 0 -> hops (forced)', () { - expect( - _contact(pathOverride: 3).pathLabel(l10n), - '3 hops (forced)', - ); + expect(_contact(pathOverride: 3).pathLabel(l10n), '3 hops (forced)'); }); test('override takes precedence over pathLength', () { From 026ec6f7decaa340c84ecbc325a7400e3268817c Mon Sep 17 00:00:00 2001 From: Ded Date: Tue, 28 Apr 2026 22:35:48 -0700 Subject: [PATCH 3/5] bump app protocol version as we support v4+ features (#398) --- lib/connector/meshcore_protocol.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 396d78b3..02e75db3 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -320,7 +320,7 @@ const int maxPathSize = 64; const int pathHashSize = 1; const int maxNameSize = 32; const int maxFrameSize = 172; -const int appProtocolVersion = 3; +const int appProtocolVersion = 4; // Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE). const int maxTextPayloadBytes = 160; const int _sendTextMsgOverheadBytes = From 3af3cce60612f14fc019c173ad6f7038daf79b43 Mon Sep 17 00:00:00 2001 From: ZIER Date: Wed, 29 Apr 2026 11:04:36 +0200 Subject: [PATCH 4/5] latin languages heuristics --- lib/services/translation_service.dart | 49 ++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 7d76efab..f0f37fb6 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -519,12 +519,11 @@ class TranslationService extends ChangeNotifier { } String? _heuristicLanguageCode(String text) { - if (RegExp(r'[іїєґІЇЄҐ]').hasMatch(text)) { - return 'uk'; - } - if (RegExp(r'[а-яёА-ЯЁ]').hasMatch(text)) { - return 'ru'; + final trimmed = text.trim(); + if (trimmed.isEmpty) { + return null; } + if (RegExp(r'[ぁ-んァ-ン]').hasMatch(text)) { return 'ja'; } @@ -534,9 +533,43 @@ class TranslationService extends ChangeNotifier { if (RegExp(r'[\u4e00-\u9fff]').hasMatch(text)) { return 'zh'; } - // Latin-script languages can't be reliably distinguished by characters - // alone — return null so the translator always attempts translation. - return null; + + final lower = trimmed.toLowerCase(); + final patterns = { + 'uk': r'\b(привіт|дякую|будь|ласка|як|де|не|так|це|є|най|ще|може|для)\b', + 'ru': r'\b(что|это|как|не|да|нет|он|она|они|быть|есть|для|сегодня|если|уже|может)\b', + 'bg': r'\b(ще|няма|благодаря|моля|това|какво|тук|ние|вие|не|със|за)\b', + 'de': r'\b(der|die|das|und|ist|nicht|ein|eine|ich|für|mit|auf|zu|auch|als|an|im|am|es|dem|den|sich|von)\b', + 'en': r'\b(the|and|is|you|for|with|from|not|that|this|have|be|are|was|were|but|can|will|your|what|when|how|they)\b', + 'es': r'\b(el|la|los|las|es|que|de|en|con|por|para|no|un|una|se|como|su|al|del|está)\b', + 'fr': r'\b(le|la|les|un|une|et|est|que|qui|pour|dans|pas|avec|sur|ne|vous|il|elle|des|ce|cette|je|tu|nous|vous)\b', + 'it': r'\b(il|la|lo|un|una|che|di|da|in|per|con|non|si|mi|ti|noi|voi|lui|lei)\b', + 'pt': r'\b(os|as|que|de|do|da|em|para|com|por|não|uma|um|se|você|também)\b', + 'nl': r'\b(de|het|een|en|is|niet|dat|wat|je|ik|op|aan|voor|met|als|nog|zijn)\b', + 'sv': r'\b(och|är|det|att|som|en|på|inte|har|var|men|du|jag|vi|ni|den|detta)\b', + 'pl': r'\b(na|się|nie|jest|to|że|do|od|dla|czy|tak|ale|ma|jak|on|ona|my)\b', + 'sk': r'\b(je|na|so|že|do|od|za|si|to|ten|tá|tí|ako|má|nie|som|sa)\b', + 'sl': r'\b(in|je|na|se|da|za|od|ne|to|ta|so|kako|bo|sem|si)\b', + 'hu': r'\b(az|és|nem|van|volt|hogy|mit|mire|ki|mi|ez|azért|is|de|ha|te|ő|mi|itt)\b', + }; + + final scores = {}; + for (final entry in patterns.entries) { + scores[entry.key] = RegExp(entry.value, caseSensitive: false) + .allMatches(lower) + .length; + } + + final sorted = scores.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + if (sorted.isEmpty || sorted.first.value == 0) { + return null; + } + if (sorted.length > 1 && sorted.first.value == sorted[1].value) { + return null; + } + + return sorted.first.key; } String _languageLabel(String code) { From 38fece33131217efb88bd9ba19bc177bf187dbe6 Mon Sep 17 00:00:00 2001 From: ZIER Date: Wed, 29 Apr 2026 11:51:50 +0200 Subject: [PATCH 5/5] replace pattern with String. --- lib/services/translation_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index f0f37fb6..46cf7ea8 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -535,7 +535,7 @@ class TranslationService extends ChangeNotifier { } final lower = trimmed.toLowerCase(); - final patterns = { + final patterns = { 'uk': r'\b(привіт|дякую|будь|ласка|як|де|не|так|це|є|най|ще|може|для)\b', 'ru': r'\b(что|это|как|не|да|нет|он|она|они|быть|есть|для|сегодня|если|уже|может)\b', 'bg': r'\b(ще|няма|благодаря|моля|това|какво|тук|ние|вие|не|със|за)\b',