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 = diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 870a2cdc..c16d091a 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1632,10 +1632,10 @@ class AppLocalizationsRu extends AppLocalizations { } @override - String get chat_markAsUnread => 'Mark as Unread'; + String get chat_markAsUnread => 'Пометить как непрочитанные'; @override - String get chat_newMessages => 'New messages'; + String get chat_newMessages => 'Новые сообщения'; @override String get chat_openLink => 'Открыть ссылку?'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 80b4b9ff..7087dd37 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -376,6 +376,8 @@ "chat_direct": "Прямой", "chat_poiShared": "Точка интереса отправлена", "chat_unread": "Непрочитанных: {count}", + "chat_markAsUnread": "Пометить как непрочитанные", + "chat_newMessages": "Новые сообщения", "map_title": "Карта нод", "map_noNodesWithLocation": "Нет нод с данными о местоположении", "map_nodesNeedGps": "Ноды должны передавать свои GPS-координаты, чтобы отображаться на карте", 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 0bd062f4..ffd936a4 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) { @@ -1557,7 +1558,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 46d89977..8d3bc66c 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1234,8 +1234,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/lib/services/translation_service.dart b/lib/services/translation_service.dart index 7d76efab..294a7848 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,55 @@ 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) { diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 379e36fa..93e46829 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flserial + jni ) set(PLUGIN_BUNDLED_LIBRARIES) 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', () { diff --git a/untranslated.json b/untranslated.json index ec563845..fe697333 100644 --- a/untranslated.json +++ b/untranslated.json @@ -54,11 +54,6 @@ "chat_newMessages" ], - "ru": [ - "chat_markAsUnread", - "chat_newMessages" - ], - "sk": [ "chat_markAsUnread", "chat_newMessages" diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index f02857f4..533a1712 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flserial flutter_local_notifications_windows + jni ) set(PLUGIN_BUNDLED_LIBRARIES)