2026-02-22 16:00:51 -08:00
|
|
|
import 'dart:collection';
|
2026-01-15 19:15:42 -07:00
|
|
|
import 'dart:math';
|
2026-02-14 00:10:34 -08:00
|
|
|
import 'dart:typed_data';
|
2026-01-15 19:15:42 -07:00
|
|
|
|
2026-03-12 23:08:46 -07:00
|
|
|
import 'package:flutter/foundation.dart';
|
2025-12-26 11:42:02 -07:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
|
|
|
import 'package:latlong2/latlong.dart';
|
2026-02-14 00:10:34 -08:00
|
|
|
import 'package:meshcore_open/screens/path_trace_map.dart';
|
Add a signal readout for the nearest repeater. With improvements to app bar and other UI polish. (#200)
* Refactor Cayenne LPP parsing with error handling and logging
- Added error handling and logging to the Cayenne LPP parsing methods to manage malformed data gracefully.
- Improved the structure of the parsing logic for better readability and maintainability.
- Updated the Contact model to include error handling during frame parsing.
- Refactored Channels, Contacts, Map, and Neighbours screens to utilize a new AppBarTitle widget for consistent app bar design.
- Enhanced the BatteryIndicator widget to display SNR information for direct repeaters.
- Introduced SNRUi class for better management of SNR icon and text representation.
- Improved error handling in PathTraceMap and Neighbours screens to log errors appropriately.
* Fix trace route bytes generation logic in Contact model
* Ignore advertisements from self in MeshCoreConnector
* Refactor PathTraceData to use List<double> for snrData and adjust data mapping in PathTraceMapScreen
* Add SNRIndicator to AppBar and refactor BatteryIndicator layout
* Enhance path management dialog to display direct repeaters with color coding based on signal strength
* Remove unused import from SNR indicator widget
* Update lib/models/contact.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/screens/path_trace_map.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/widgets/battery_indicator.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/helpers/cayenne_lpp.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Refactor packet handling to skip only the RSSI byte for improved reliability
* Add SNR indicator localization and update UI references for nearby repeaters
* Handle loading state and error parsing in PathTraceMapScreen; update SNR indicator dialog content layout
* Throw an exception for unsupported LPP types in CayenneLpp class
* Refactor AppBarTitle widget to remove unused style parameter; update related screens to reflect changes
Improve SNR handling by adding validation for spreading factor range in snrUiFromSNR function
Update contact handling in MeshCoreConnector to fix variable naming and improve readability
Stop parsing unsupported LPP types in CayenneLpp to avoid misalignment
* Sort direct repeaters by last updated time and SNR; limit to top three for improved path management dialog
* Prevent notifications for chat and sensor adverts without a valid path
* Implement ranking system for direct repeaters based on SNR and recency; update related UI components to reflect changes
* Refactor localization keys for "neighbors" terminology across multiple languages
- Updated localization keys from "neighbours" to "neighbors" in the following files:
- app_localizations_bg.dart
- app_localizations_de.dart
- app_localizations_en.dart
- app_localizations_es.dart
- app_localizations_fr.dart
- app_localizations_it.dart
- app_localizations_nl.dart
- app_localizations_pl.dart
- app_localizations_pt.dart
- app_localizations_ru.dart
- app_localizations_sk.dart
- app_localizations_sl.dart
- app_localizations_sv.dart
- app_localizations_uk.dart
- app_localizations_zh.dart
- Updated corresponding ARB files to reflect the changes in keys.
- Renamed the NeighboursScreen to NeighborsScreen in the chat and repeater hub screens for consistency.
* Adjust ranking calculation for direct repeaters by adding offset to SNR for improved accuracy
* Fix typo in variable name for second direct repeater in path management dialog
* Refactor ranking calculation for direct repeaters and update path handling in channel message screens
* Refactor path handling in ChannelMessagePathScreen to improve logic for outgoing messages and channel messages
* Fix AppBarTitle horizontal overflow with long titles (#187)
* Initial plan
* Wrap title Column in Expanded to prevent horizontal overflow in AppBarTitle
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
* Refactor AppBarTitle widget to simplify Text widget initialization
* Add "Show All Paths" feature to chat path management
- Implemented localization for "Show All Paths" in multiple languages (DE, EN, ES, FR, IT, NL, PL, PT, RU, SK, SL, SV, UK, ZH).
- Updated path management dialog to include a toggle for showing all paths.
- Refactored path history display logic to conditionally show paths based on the toggle state.
- Cleaned up unused print statements and improved code readability in path tracing and chat screens.
* Refactor FeatureToggleRow visibility in chat and path management dialogs based on repeaters list
* Remove unused import of 'dart:ffi' in path_trace_map.dart
* Refactor repeater management logic and update UI state handling in chat and path management dialogs
* Refactor RX data handling and improve repeater management logic in chat and path management dialogs
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
2026-02-20 20:27:38 -08:00
|
|
|
import 'package:meshcore_open/widgets/app_bar.dart';
|
2025-12-26 11:42:02 -07:00
|
|
|
import 'package:provider/provider.dart';
|
|
|
|
|
|
|
|
|
|
import '../connector/meshcore_connector.dart';
|
2026-01-11 17:13:50 -07:00
|
|
|
import '../l10n/l10n.dart';
|
2025-12-26 11:42:02 -07:00
|
|
|
import '../connector/meshcore_protocol.dart';
|
2026-02-21 01:08:23 -05:00
|
|
|
import '../models/app_settings.dart';
|
2025-12-26 11:42:02 -07:00
|
|
|
import '../models/channel.dart';
|
|
|
|
|
import '../models/contact.dart';
|
|
|
|
|
import '../services/app_settings_service.dart';
|
2026-03-06 15:02:37 -07:00
|
|
|
import '../services/path_history_service.dart';
|
2025-12-26 11:42:02 -07:00
|
|
|
import '../services/map_marker_service.dart';
|
2025-12-27 15:32:32 -07:00
|
|
|
import '../services/map_tile_cache_service.dart';
|
|
|
|
|
import '../utils/contact_search.dart';
|
|
|
|
|
import '../utils/route_transitions.dart';
|
|
|
|
|
import '../widgets/quick_switch_bar.dart';
|
2026-02-23 00:01:13 -05:00
|
|
|
import '../icons/los_icon.dart';
|
2025-12-27 15:32:32 -07:00
|
|
|
import 'channels_screen.dart';
|
2025-12-26 11:42:02 -07:00
|
|
|
import 'chat_screen.dart';
|
2025-12-27 15:32:32 -07:00
|
|
|
import 'contacts_screen.dart';
|
2026-01-05 16:41:46 -08:00
|
|
|
import '../widgets/repeater_login_dialog.dart';
|
2026-01-11 11:51:40 -07:00
|
|
|
import '../widgets/room_login_dialog.dart';
|
2026-01-05 16:41:46 -08:00
|
|
|
import 'repeater_hub_screen.dart';
|
2025-12-27 15:32:32 -07:00
|
|
|
import 'settings_screen.dart';
|
2026-02-21 01:08:23 -05:00
|
|
|
import 'line_of_sight_map_screen.dart';
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
class MapScreen extends StatefulWidget {
|
|
|
|
|
final LatLng? highlightPosition;
|
|
|
|
|
final String? highlightLabel;
|
|
|
|
|
final double highlightZoom;
|
2025-12-27 15:32:32 -07:00
|
|
|
final bool hideBackButton;
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
const MapScreen({
|
|
|
|
|
super.key,
|
|
|
|
|
this.highlightPosition,
|
|
|
|
|
this.highlightLabel,
|
|
|
|
|
this.highlightZoom = 15.0,
|
2025-12-27 15:32:32 -07:00
|
|
|
this.hideBackButton = false,
|
2025-12-26 11:42:02 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<MapScreen> createState() => _MapScreenState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _MapScreenState extends State<MapScreen> {
|
2026-03-12 23:08:46 -07:00
|
|
|
// Zoom level at which node labels start to appear
|
2026-02-22 16:00:51 -08:00
|
|
|
static const double _labelZoomThreshold = 14.0;
|
2026-02-21 01:08:23 -05:00
|
|
|
|
2025-12-26 11:42:02 -07:00
|
|
|
final MapController _mapController = MapController();
|
|
|
|
|
final MapMarkerService _markerService = MapMarkerService();
|
|
|
|
|
final Set<String> _hiddenMarkerIds = {};
|
|
|
|
|
Set<String> _removedMarkerIds = {};
|
2026-02-14 00:10:34 -08:00
|
|
|
bool _isBuildingPathTrace = false;
|
2025-12-26 11:42:02 -07:00
|
|
|
bool _isSelectingPoi = false;
|
2026-01-15 19:15:42 -07:00
|
|
|
bool _hasInitializedMap = false;
|
|
|
|
|
bool _removedMarkersLoaded = false;
|
2026-02-14 00:10:34 -08:00
|
|
|
final List<int> _pathTrace = [];
|
2026-03-24 17:45:54 -07:00
|
|
|
final List<Contact> _pathTraceContacts = [];
|
2026-02-14 00:10:34 -08:00
|
|
|
final List<LatLng> _points = [];
|
|
|
|
|
final List<Polyline> _polylines = [];
|
2026-02-12 15:46:28 -05:00
|
|
|
bool _legendExpanded = false;
|
2026-02-21 01:08:23 -05:00
|
|
|
bool _showNodeLabels = true;
|
2026-03-06 15:02:37 -07:00
|
|
|
List<_GuessedLocation> _cachedGuessedLocations = [];
|
|
|
|
|
String _guessedLocationsCacheKey = '';
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_loadRemovedMarkers();
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
context.read<MeshCoreConnector>().getChannels();
|
|
|
|
|
if (widget.highlightPosition != null) {
|
|
|
|
|
_mapController.move(widget.highlightPosition!, widget.highlightZoom);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _loadRemovedMarkers() async {
|
|
|
|
|
final ids = await _markerService.loadRemovedIds();
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_removedMarkerIds = ids;
|
2026-01-15 19:15:42 -07:00
|
|
|
_removedMarkersLoaded = true;
|
2025-12-26 11:42:02 -07:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 23:08:46 -07:00
|
|
|
bool _checkLocationPlausibility(double lat, double lon) {
|
|
|
|
|
const double epsilon = 1e-6;
|
|
|
|
|
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
|
|
|
|
|
lat >= -90.0 &&
|
|
|
|
|
lat <= 90.0 &&
|
|
|
|
|
lon >= -180.0 &&
|
|
|
|
|
lon <= 180.0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 19:28:08 -08:00
|
|
|
double _standardDeviation(List<double> values) {
|
|
|
|
|
if (values.length <= 1) {
|
|
|
|
|
return 0.0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final mean = values.reduce((a, b) => a + b) / values.length;
|
|
|
|
|
|
|
|
|
|
double sumSquaredDiff = 0.0;
|
|
|
|
|
for (final value in values) {
|
|
|
|
|
final diff = value - mean;
|
|
|
|
|
sumSquaredDiff += diff * diff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sample standard deviation (n-1) — most appropriate here
|
|
|
|
|
final variance = sumSquaredDiff / (values.length - 1);
|
|
|
|
|
|
|
|
|
|
return sqrt(variance);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 19:15:42 -07:00
|
|
|
// Calculate zoom level based on the spread of points (std deviation in degrees)
|
|
|
|
|
double _zoomFromStdDev(double latStdDev, double lonStdDev) {
|
|
|
|
|
final maxSpread = max(latStdDev, lonStdDev);
|
|
|
|
|
if (maxSpread <= 0) return 13.0;
|
Add a signal readout for the nearest repeater. With improvements to app bar and other UI polish. (#200)
* Refactor Cayenne LPP parsing with error handling and logging
- Added error handling and logging to the Cayenne LPP parsing methods to manage malformed data gracefully.
- Improved the structure of the parsing logic for better readability and maintainability.
- Updated the Contact model to include error handling during frame parsing.
- Refactored Channels, Contacts, Map, and Neighbours screens to utilize a new AppBarTitle widget for consistent app bar design.
- Enhanced the BatteryIndicator widget to display SNR information for direct repeaters.
- Introduced SNRUi class for better management of SNR icon and text representation.
- Improved error handling in PathTraceMap and Neighbours screens to log errors appropriately.
* Fix trace route bytes generation logic in Contact model
* Ignore advertisements from self in MeshCoreConnector
* Refactor PathTraceData to use List<double> for snrData and adjust data mapping in PathTraceMapScreen
* Add SNRIndicator to AppBar and refactor BatteryIndicator layout
* Enhance path management dialog to display direct repeaters with color coding based on signal strength
* Remove unused import from SNR indicator widget
* Update lib/models/contact.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/screens/path_trace_map.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/widgets/battery_indicator.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/helpers/cayenne_lpp.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Refactor packet handling to skip only the RSSI byte for improved reliability
* Add SNR indicator localization and update UI references for nearby repeaters
* Handle loading state and error parsing in PathTraceMapScreen; update SNR indicator dialog content layout
* Throw an exception for unsupported LPP types in CayenneLpp class
* Refactor AppBarTitle widget to remove unused style parameter; update related screens to reflect changes
Improve SNR handling by adding validation for spreading factor range in snrUiFromSNR function
Update contact handling in MeshCoreConnector to fix variable naming and improve readability
Stop parsing unsupported LPP types in CayenneLpp to avoid misalignment
* Sort direct repeaters by last updated time and SNR; limit to top three for improved path management dialog
* Prevent notifications for chat and sensor adverts without a valid path
* Implement ranking system for direct repeaters based on SNR and recency; update related UI components to reflect changes
* Refactor localization keys for "neighbors" terminology across multiple languages
- Updated localization keys from "neighbours" to "neighbors" in the following files:
- app_localizations_bg.dart
- app_localizations_de.dart
- app_localizations_en.dart
- app_localizations_es.dart
- app_localizations_fr.dart
- app_localizations_it.dart
- app_localizations_nl.dart
- app_localizations_pl.dart
- app_localizations_pt.dart
- app_localizations_ru.dart
- app_localizations_sk.dart
- app_localizations_sl.dart
- app_localizations_sv.dart
- app_localizations_uk.dart
- app_localizations_zh.dart
- Updated corresponding ARB files to reflect the changes in keys.
- Renamed the NeighboursScreen to NeighborsScreen in the chat and repeater hub screens for consistency.
* Adjust ranking calculation for direct repeaters by adding offset to SNR for improved accuracy
* Fix typo in variable name for second direct repeater in path management dialog
* Refactor ranking calculation for direct repeaters and update path handling in channel message screens
* Refactor path handling in ChannelMessagePathScreen to improve logic for outgoing messages and channel messages
* Fix AppBarTitle horizontal overflow with long titles (#187)
* Initial plan
* Wrap title Column in Expanded to prevent horizontal overflow in AppBarTitle
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
* Refactor AppBarTitle widget to simplify Text widget initialization
* Add "Show All Paths" feature to chat path management
- Implemented localization for "Show All Paths" in multiple languages (DE, EN, ES, FR, IT, NL, PL, PT, RU, SK, SL, SV, UK, ZH).
- Updated path management dialog to include a toggle for showing all paths.
- Refactored path history display logic to conditionally show paths based on the toggle state.
- Cleaned up unused print statements and improved code readability in path tracing and chat screens.
* Refactor FeatureToggleRow visibility in chat and path management dialogs based on repeaters list
* Remove unused import of 'dart:ffi' in path_trace_map.dart
* Refactor repeater management logic and update UI state handling in chat and path management dialogs
* Refactor RX data handling and improve repeater management logic in chat and path management dialogs
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
2026-02-20 20:27:38 -08:00
|
|
|
// Approximate: each zoom level halves the visible area
|
2026-01-15 19:15:42 -07:00
|
|
|
// ~0.01 degrees spread -> zoom 13, ~0.1 -> zoom 10, ~1.0 -> zoom 7
|
|
|
|
|
final zoom = 10.0 - log(maxSpread * 10 + 1) / ln10 * 3;
|
|
|
|
|
return zoom.clamp(4.0, 15.0);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 11:42:02 -07:00
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-03-06 15:02:37 -07:00
|
|
|
return Consumer3<MeshCoreConnector, AppSettingsService, PathHistoryService>(
|
|
|
|
|
builder: (context, connector, settingsService, pathHistory, child) {
|
2025-12-27 15:32:32 -07:00
|
|
|
final tileCache = context.read<MapTileCacheService>();
|
2025-12-26 11:42:02 -07:00
|
|
|
final settings = settingsService.settings;
|
2026-03-26 16:46:01 -07:00
|
|
|
final allContacts = connector.allContacts;
|
2026-03-12 23:08:46 -07:00
|
|
|
|
|
|
|
|
final contacts = settings.mapShowDiscoveryContacts
|
|
|
|
|
? allContacts
|
|
|
|
|
: allContacts.where((c) => c.isActive).toList();
|
|
|
|
|
|
2025-12-26 11:42:02 -07:00
|
|
|
final highlightPosition = widget.highlightPosition;
|
|
|
|
|
final sharedMarkers = settings.mapShowMarkers
|
|
|
|
|
? _collectSharedMarkers(connector)
|
2026-01-15 19:11:13 -07:00
|
|
|
.where(
|
|
|
|
|
(marker) =>
|
|
|
|
|
!_hiddenMarkerIds.contains(marker.id) &&
|
|
|
|
|
!_removedMarkerIds.contains(marker.id),
|
|
|
|
|
)
|
|
|
|
|
.toList()
|
2025-12-26 11:42:02 -07:00
|
|
|
: <_SharedMarker>[];
|
|
|
|
|
|
|
|
|
|
// Filter by time
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
final filteredByTime = settings.mapTimeFilterHours == 0
|
|
|
|
|
? contacts
|
|
|
|
|
: contacts.where((c) {
|
2026-01-15 19:11:13 -07:00
|
|
|
final hoursSinceLastSeen = now.difference(c.lastSeen).inHours;
|
2025-12-26 11:42:02 -07:00
|
|
|
return hoursSinceLastSeen <= settings.mapTimeFilterHours;
|
|
|
|
|
}).toList();
|
|
|
|
|
|
|
|
|
|
// Filter by key prefix
|
|
|
|
|
final keyPrefix = settings.mapKeyPrefix.trim();
|
2026-01-15 19:11:13 -07:00
|
|
|
final filteredByKeyPrefix =
|
|
|
|
|
(settings.mapKeyPrefixEnabled && keyPrefix.isNotEmpty)
|
2025-12-26 11:42:02 -07:00
|
|
|
? filteredByTime.where((c) {
|
2026-01-15 19:11:13 -07:00
|
|
|
return c.publicKeyHex.toLowerCase().startsWith(
|
|
|
|
|
keyPrefix.toLowerCase(),
|
|
|
|
|
);
|
2025-12-26 11:42:02 -07:00
|
|
|
}).toList()
|
|
|
|
|
: filteredByTime;
|
|
|
|
|
|
|
|
|
|
// Filter by location
|
2026-03-12 23:08:46 -07:00
|
|
|
final contactsWithLocation = filteredByKeyPrefix.where((c) {
|
2026-03-14 17:51:24 -07:00
|
|
|
return c.hasLocation;
|
2026-03-12 23:08:46 -07:00
|
|
|
}).toList();
|
2025-12-26 11:42:02 -07:00
|
|
|
|
2026-03-06 15:02:37 -07:00
|
|
|
// All contacts with a known location — used as anchors regardless of
|
|
|
|
|
// time/key-prefix filters so that repeaters are always available.
|
2026-03-12 23:08:46 -07:00
|
|
|
final allContactsWithLocation = allContacts
|
2026-03-14 17:51:24 -07:00
|
|
|
.where((c) => c.hasLocation)
|
2026-03-06 15:02:37 -07:00
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
// Compute guessed locations with caching
|
|
|
|
|
final maxRangeKm = _estimateLoRaRangeKm(connector);
|
2026-03-06 15:11:21 -07:00
|
|
|
final filteredKeys = filteredByKeyPrefix
|
2026-03-06 15:18:48 -07:00
|
|
|
.map((c) => '${c.publicKeyHex}:${c.path.join("-")}')
|
2026-03-06 15:11:21 -07:00
|
|
|
.join(',');
|
|
|
|
|
final anchorKeys = allContactsWithLocation
|
2026-03-06 15:18:48 -07:00
|
|
|
.map(
|
|
|
|
|
(c) =>
|
|
|
|
|
'${c.publicKeyHex}:${c.latitude}:${c.longitude}:${c.path.isNotEmpty ? c.path.last : ""}',
|
|
|
|
|
)
|
2026-03-06 15:11:21 -07:00
|
|
|
.join(',');
|
2026-03-06 15:02:37 -07:00
|
|
|
final cacheKey =
|
2026-03-06 15:11:21 -07:00
|
|
|
'$filteredKeys|$anchorKeys|${pathHistory.version}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}';
|
2026-03-06 15:02:37 -07:00
|
|
|
if (cacheKey != _guessedLocationsCacheKey) {
|
|
|
|
|
_guessedLocationsCacheKey = cacheKey;
|
|
|
|
|
_cachedGuessedLocations = settings.mapShowGuessedLocations
|
|
|
|
|
? _computeGuessedLocations(
|
|
|
|
|
filteredByKeyPrefix,
|
|
|
|
|
allContactsWithLocation,
|
|
|
|
|
pathHistory,
|
|
|
|
|
maxRangeKm,
|
|
|
|
|
)
|
|
|
|
|
: [];
|
|
|
|
|
}
|
|
|
|
|
final guessedLocations = settings.mapShowGuessedLocations
|
|
|
|
|
? _cachedGuessedLocations
|
|
|
|
|
: <_GuessedLocation>[];
|
|
|
|
|
|
2026-02-14 00:10:34 -08:00
|
|
|
_polylines.clear();
|
|
|
|
|
_polylines.addAll(
|
|
|
|
|
_points.length > 1
|
|
|
|
|
? [
|
|
|
|
|
Polyline(
|
|
|
|
|
points: _points,
|
|
|
|
|
strokeWidth: 4,
|
|
|
|
|
color: Colors.blueAccent,
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
: <Polyline>[],
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-15 19:15:42 -07:00
|
|
|
// Calculate center and zoom of all nodes, or default to (0, 0)
|
2025-12-26 11:42:02 -07:00
|
|
|
LatLng center = const LatLng(0, 0);
|
2026-01-15 19:15:42 -07:00
|
|
|
double initialZoom = 10.0;
|
2026-01-15 19:11:13 -07:00
|
|
|
final hasMapContent =
|
|
|
|
|
contactsWithLocation.isNotEmpty ||
|
2025-12-26 11:42:02 -07:00
|
|
|
sharedMarkers.isNotEmpty ||
|
|
|
|
|
_isSelectingPoi ||
|
|
|
|
|
highlightPosition != null;
|
|
|
|
|
if (contactsWithLocation.isNotEmpty || sharedMarkers.isNotEmpty) {
|
2026-01-14 19:28:08 -08:00
|
|
|
final allPoints = [
|
2026-01-15 19:11:13 -07:00
|
|
|
...contactsWithLocation.map(
|
|
|
|
|
(c) => LatLng(c.latitude!, c.longitude!),
|
|
|
|
|
),
|
2026-01-14 19:28:08 -08:00
|
|
|
...sharedMarkers.map((m) => m.position),
|
|
|
|
|
];
|
|
|
|
|
if (allPoints.length >= 3) {
|
|
|
|
|
final latValues = allPoints.map((p) => p.latitude).toList();
|
|
|
|
|
final lonValues = allPoints.map((p) => p.longitude).toList();
|
2026-01-15 19:11:13 -07:00
|
|
|
|
2026-01-15 19:15:42 -07:00
|
|
|
final meanLat =
|
|
|
|
|
latValues.reduce((a, b) => a + b) / latValues.length;
|
|
|
|
|
final meanLon =
|
|
|
|
|
lonValues.reduce((a, b) => a + b) / lonValues.length;
|
2026-01-14 19:28:08 -08:00
|
|
|
final latStdDev = _standardDeviation(latValues);
|
|
|
|
|
final lonStdDev = _standardDeviation(lonValues);
|
2026-01-15 19:15:42 -07:00
|
|
|
|
|
|
|
|
final filteredPoints = allPoints
|
2026-01-15 19:11:13 -07:00
|
|
|
.where(
|
2026-01-15 19:15:42 -07:00
|
|
|
(p) =>
|
|
|
|
|
(p.latitude - meanLat).abs() <= latStdDev * 2 &&
|
|
|
|
|
(p.longitude - meanLon).abs() <= lonStdDev * 2,
|
2026-01-15 19:11:13 -07:00
|
|
|
)
|
2026-01-14 19:28:08 -08:00
|
|
|
.toList();
|
2026-01-15 19:15:42 -07:00
|
|
|
|
|
|
|
|
if (filteredPoints.isNotEmpty) {
|
|
|
|
|
final filteredLatValues = filteredPoints
|
|
|
|
|
.map((p) => p.latitude)
|
|
|
|
|
.toList();
|
|
|
|
|
final filteredLonValues = filteredPoints
|
|
|
|
|
.map((p) => p.longitude)
|
|
|
|
|
.toList();
|
|
|
|
|
final avgLat = filteredLatValues.reduce((a, b) => a + b);
|
|
|
|
|
final avgLon = filteredLonValues.reduce((a, b) => a + b);
|
|
|
|
|
center = LatLng(
|
|
|
|
|
avgLat / filteredPoints.length,
|
|
|
|
|
avgLon / filteredPoints.length,
|
|
|
|
|
);
|
|
|
|
|
// Use std deviation of filtered points for zoom
|
|
|
|
|
final filteredLatStdDev = _standardDeviation(filteredLatValues);
|
|
|
|
|
final filteredLonStdDev = _standardDeviation(filteredLonValues);
|
|
|
|
|
initialZoom = _zoomFromStdDev(
|
|
|
|
|
filteredLatStdDev,
|
|
|
|
|
filteredLonStdDev,
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
center = LatLng(meanLat, meanLon);
|
|
|
|
|
initialZoom = _zoomFromStdDev(latStdDev, lonStdDev);
|
|
|
|
|
}
|
2026-01-14 19:28:08 -08:00
|
|
|
} else {
|
2026-01-15 19:15:42 -07:00
|
|
|
double avgLat = 0.0;
|
|
|
|
|
double avgLon = 0.0;
|
2026-01-14 19:28:08 -08:00
|
|
|
for (final point in allPoints) {
|
|
|
|
|
avgLat += point.latitude;
|
|
|
|
|
avgLon += point.longitude;
|
|
|
|
|
}
|
|
|
|
|
center = LatLng(
|
|
|
|
|
avgLat / allPoints.length,
|
|
|
|
|
avgLon / allPoints.length,
|
|
|
|
|
);
|
2026-01-15 19:15:42 -07:00
|
|
|
initialZoom = 12.0;
|
2025-12-26 11:42:02 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (highlightPosition != null) {
|
|
|
|
|
center = highlightPosition;
|
2026-01-15 19:15:42 -07:00
|
|
|
initialZoom = widget.highlightZoom;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Re center map after removed markers have loaded
|
2026-02-01 18:37:14 -07:00
|
|
|
if (!_hasInitializedMap && _removedMarkersLoaded) {
|
2026-01-15 19:15:42 -07:00
|
|
|
_hasInitializedMap = true;
|
2026-02-21 01:08:23 -05:00
|
|
|
_showNodeLabels = initialZoom >= _labelZoomThreshold;
|
2026-02-01 18:37:14 -07:00
|
|
|
if (hasMapContent) {
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
_mapController.move(center, initialZoom);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-12-26 11:42:02 -07:00
|
|
|
}
|
|
|
|
|
|
2025-12-29 20:01:16 -07:00
|
|
|
final allowBack = !connector.isConnected;
|
|
|
|
|
|
|
|
|
|
return PopScope(
|
|
|
|
|
canPop: allowBack,
|
|
|
|
|
child: Scaffold(
|
|
|
|
|
appBar: AppBar(
|
Add a signal readout for the nearest repeater. With improvements to app bar and other UI polish. (#200)
* Refactor Cayenne LPP parsing with error handling and logging
- Added error handling and logging to the Cayenne LPP parsing methods to manage malformed data gracefully.
- Improved the structure of the parsing logic for better readability and maintainability.
- Updated the Contact model to include error handling during frame parsing.
- Refactored Channels, Contacts, Map, and Neighbours screens to utilize a new AppBarTitle widget for consistent app bar design.
- Enhanced the BatteryIndicator widget to display SNR information for direct repeaters.
- Introduced SNRUi class for better management of SNR icon and text representation.
- Improved error handling in PathTraceMap and Neighbours screens to log errors appropriately.
* Fix trace route bytes generation logic in Contact model
* Ignore advertisements from self in MeshCoreConnector
* Refactor PathTraceData to use List<double> for snrData and adjust data mapping in PathTraceMapScreen
* Add SNRIndicator to AppBar and refactor BatteryIndicator layout
* Enhance path management dialog to display direct repeaters with color coding based on signal strength
* Remove unused import from SNR indicator widget
* Update lib/models/contact.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/screens/path_trace_map.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/widgets/battery_indicator.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/helpers/cayenne_lpp.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Refactor packet handling to skip only the RSSI byte for improved reliability
* Add SNR indicator localization and update UI references for nearby repeaters
* Handle loading state and error parsing in PathTraceMapScreen; update SNR indicator dialog content layout
* Throw an exception for unsupported LPP types in CayenneLpp class
* Refactor AppBarTitle widget to remove unused style parameter; update related screens to reflect changes
Improve SNR handling by adding validation for spreading factor range in snrUiFromSNR function
Update contact handling in MeshCoreConnector to fix variable naming and improve readability
Stop parsing unsupported LPP types in CayenneLpp to avoid misalignment
* Sort direct repeaters by last updated time and SNR; limit to top three for improved path management dialog
* Prevent notifications for chat and sensor adverts without a valid path
* Implement ranking system for direct repeaters based on SNR and recency; update related UI components to reflect changes
* Refactor localization keys for "neighbors" terminology across multiple languages
- Updated localization keys from "neighbours" to "neighbors" in the following files:
- app_localizations_bg.dart
- app_localizations_de.dart
- app_localizations_en.dart
- app_localizations_es.dart
- app_localizations_fr.dart
- app_localizations_it.dart
- app_localizations_nl.dart
- app_localizations_pl.dart
- app_localizations_pt.dart
- app_localizations_ru.dart
- app_localizations_sk.dart
- app_localizations_sl.dart
- app_localizations_sv.dart
- app_localizations_uk.dart
- app_localizations_zh.dart
- Updated corresponding ARB files to reflect the changes in keys.
- Renamed the NeighboursScreen to NeighborsScreen in the chat and repeater hub screens for consistency.
* Adjust ranking calculation for direct repeaters by adding offset to SNR for improved accuracy
* Fix typo in variable name for second direct repeater in path management dialog
* Refactor ranking calculation for direct repeaters and update path handling in channel message screens
* Refactor path handling in ChannelMessagePathScreen to improve logic for outgoing messages and channel messages
* Fix AppBarTitle horizontal overflow with long titles (#187)
* Initial plan
* Wrap title Column in Expanded to prevent horizontal overflow in AppBarTitle
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
* Refactor AppBarTitle widget to simplify Text widget initialization
* Add "Show All Paths" feature to chat path management
- Implemented localization for "Show All Paths" in multiple languages (DE, EN, ES, FR, IT, NL, PL, PT, RU, SK, SL, SV, UK, ZH).
- Updated path management dialog to include a toggle for showing all paths.
- Refactored path history display logic to conditionally show paths based on the toggle state.
- Cleaned up unused print statements and improved code readability in path tracing and chat screens.
* Refactor FeatureToggleRow visibility in chat and path management dialogs based on repeaters list
* Remove unused import of 'dart:ffi' in path_trace_map.dart
* Refactor repeater management logic and update UI state handling in chat and path management dialogs
* Refactor RX data handling and improve repeater management logic in chat and path management dialogs
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
2026-02-20 20:27:38 -08:00
|
|
|
title: AppBarTitle(context.l10n.map_title),
|
2025-12-29 20:01:16 -07:00
|
|
|
centerTitle: true,
|
2026-01-02 14:22:39 -07:00
|
|
|
automaticallyImplyLeading: false,
|
2025-12-29 20:01:16 -07:00
|
|
|
actions: [
|
2026-02-14 00:10:34 -08:00
|
|
|
if (!_isBuildingPathTrace)
|
|
|
|
|
IconButton(
|
|
|
|
|
icon: const Icon(Icons.radar),
|
2026-02-22 16:00:51 -08:00
|
|
|
onPressed: () => _startPath(
|
|
|
|
|
LatLng(connector.selfLatitude!, connector.selfLongitude!),
|
|
|
|
|
),
|
2026-02-14 00:10:34 -08:00
|
|
|
tooltip: context.l10n.contacts_pathTrace,
|
|
|
|
|
),
|
2026-02-21 01:08:23 -05:00
|
|
|
if (!_isBuildingPathTrace)
|
|
|
|
|
IconButton(
|
2026-02-23 00:01:13 -05:00
|
|
|
icon: const LosIcon(),
|
2026-02-21 01:08:23 -05:00
|
|
|
onPressed: () {
|
|
|
|
|
final candidates = <LineOfSightEndpoint>[];
|
|
|
|
|
if (connector.selfLatitude != null &&
|
|
|
|
|
connector.selfLongitude != null) {
|
|
|
|
|
candidates.add(
|
|
|
|
|
LineOfSightEndpoint(
|
|
|
|
|
label: context.l10n.pathTrace_you,
|
|
|
|
|
point: LatLng(
|
|
|
|
|
connector.selfLatitude!,
|
|
|
|
|
connector.selfLongitude!,
|
|
|
|
|
),
|
|
|
|
|
color: Colors.teal,
|
|
|
|
|
icon: Icons.person_pin_circle,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
for (final c in contactsWithLocation) {
|
|
|
|
|
candidates.add(
|
|
|
|
|
LineOfSightEndpoint(
|
|
|
|
|
label: c.name,
|
|
|
|
|
point: LatLng(c.latitude!, c.longitude!),
|
|
|
|
|
color: _getNodeColor(c.type),
|
|
|
|
|
icon: _getNodeIcon(c.type),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Navigator.push(
|
|
|
|
|
context,
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (context) => LineOfSightMapScreen(
|
|
|
|
|
title: context.l10n.map_losScreenTitle,
|
|
|
|
|
candidates: candidates,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
tooltip: context.l10n.map_lineOfSight,
|
|
|
|
|
),
|
2026-01-31 15:25:34 -08:00
|
|
|
PopupMenuButton(
|
|
|
|
|
itemBuilder: (context) => [
|
|
|
|
|
PopupMenuItem(
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
const Icon(Icons.logout, color: Colors.red),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
Text(context.l10n.common_disconnect),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
onTap: () => _disconnect(context, connector),
|
|
|
|
|
),
|
|
|
|
|
PopupMenuItem(
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
const Icon(Icons.settings),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
Text(context.l10n.settings_title),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
onTap: () => Navigator.push(
|
|
|
|
|
context,
|
2026-02-04 08:32:35 -08:00
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (context) => const SettingsScreen(),
|
|
|
|
|
),
|
2026-01-31 15:25:34 -08:00
|
|
|
),
|
2026-01-15 19:11:13 -07:00
|
|
|
),
|
2026-01-31 15:25:34 -08:00
|
|
|
],
|
|
|
|
|
icon: const Icon(Icons.more_vert),
|
2025-12-27 15:32:32 -07:00
|
|
|
),
|
2025-12-29 20:01:16 -07:00
|
|
|
],
|
|
|
|
|
),
|
2026-02-01 18:37:14 -07:00
|
|
|
body: Stack(
|
2026-02-04 08:32:35 -08:00
|
|
|
children: [
|
|
|
|
|
FlutterMap(
|
|
|
|
|
mapController: _mapController,
|
|
|
|
|
options: MapOptions(
|
|
|
|
|
initialCenter: center,
|
|
|
|
|
initialZoom: initialZoom,
|
|
|
|
|
minZoom: 2.0,
|
|
|
|
|
maxZoom: 18.0,
|
|
|
|
|
interactionOptions: InteractionOptions(
|
|
|
|
|
flags: ~InteractiveFlag.rotate,
|
|
|
|
|
),
|
|
|
|
|
onTap: (_, latLng) {
|
|
|
|
|
if (_isSelectingPoi) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_isSelectingPoi = false;
|
|
|
|
|
});
|
|
|
|
|
_shareMarker(
|
|
|
|
|
context: context,
|
|
|
|
|
connector: connector,
|
|
|
|
|
position: latLng,
|
|
|
|
|
defaultLabel: context.l10n.map_pointOfInterest,
|
|
|
|
|
flags: 'poi',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onLongPress: (_, latLng) {
|
|
|
|
|
if (_isSelectingPoi) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_isSelectingPoi = false;
|
|
|
|
|
});
|
|
|
|
|
_shareMarker(
|
|
|
|
|
context: context,
|
|
|
|
|
connector: connector,
|
|
|
|
|
position: latLng,
|
|
|
|
|
defaultLabel: context.l10n.map_pointOfInterest,
|
|
|
|
|
flags: 'poi',
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_showShareMarkerAtPositionSheet(
|
|
|
|
|
context: context,
|
|
|
|
|
connector: connector,
|
|
|
|
|
position: latLng,
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-02-21 01:08:23 -05:00
|
|
|
onPositionChanged: (camera, hasGesture) {
|
|
|
|
|
final shouldShow = camera.zoom >= _labelZoomThreshold;
|
|
|
|
|
if (shouldShow != _showNodeLabels && mounted) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_showNodeLabels = shouldShow;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
2025-12-29 20:01:16 -07:00
|
|
|
),
|
2026-02-04 08:32:35 -08:00
|
|
|
children: [
|
|
|
|
|
TileLayer(
|
|
|
|
|
urlTemplate: kMapTileUrlTemplate,
|
|
|
|
|
tileProvider: tileCache.tileProvider,
|
|
|
|
|
userAgentPackageName:
|
|
|
|
|
MapTileCacheService.userAgentPackageName,
|
|
|
|
|
maxZoom: 19,
|
|
|
|
|
),
|
2026-02-14 00:10:34 -08:00
|
|
|
if (_polylines.isNotEmpty && _isBuildingPathTrace)
|
|
|
|
|
PolylineLayer(polylines: _polylines),
|
2026-02-04 08:32:35 -08:00
|
|
|
MarkerLayer(
|
|
|
|
|
markers: [
|
|
|
|
|
if (highlightPosition != null)
|
|
|
|
|
Marker(
|
|
|
|
|
point: highlightPosition,
|
|
|
|
|
width: 40,
|
|
|
|
|
height: 40,
|
2026-02-24 19:01:22 -08:00
|
|
|
child: IgnorePointer(
|
|
|
|
|
child: Icon(
|
|
|
|
|
Icons.location_on_outlined,
|
|
|
|
|
color: Colors.red[600],
|
|
|
|
|
size: 34,
|
|
|
|
|
),
|
2026-02-04 08:32:35 -08:00
|
|
|
),
|
|
|
|
|
),
|
2026-03-26 16:46:01 -07:00
|
|
|
if (!settings.mapShowOverlaps)
|
|
|
|
|
..._buildGuessedMarker(
|
|
|
|
|
guessedLocations,
|
|
|
|
|
showLabels: _showNodeLabels,
|
|
|
|
|
),
|
2026-02-21 01:08:23 -05:00
|
|
|
..._buildMarkers(
|
|
|
|
|
contactsWithLocation,
|
|
|
|
|
settings,
|
|
|
|
|
showLabels: _showNodeLabels,
|
|
|
|
|
),
|
2026-02-04 08:32:35 -08:00
|
|
|
...sharedMarkers.map(_buildSharedMarker),
|
2026-02-08 18:40:58 -08:00
|
|
|
if (connector.selfLatitude != null &&
|
|
|
|
|
connector.selfLongitude != null)
|
|
|
|
|
Marker(
|
|
|
|
|
point: LatLng(
|
|
|
|
|
connector.selfLatitude!,
|
|
|
|
|
connector.selfLongitude!,
|
|
|
|
|
),
|
Add a signal readout for the nearest repeater. With improvements to app bar and other UI polish. (#200)
* Refactor Cayenne LPP parsing with error handling and logging
- Added error handling and logging to the Cayenne LPP parsing methods to manage malformed data gracefully.
- Improved the structure of the parsing logic for better readability and maintainability.
- Updated the Contact model to include error handling during frame parsing.
- Refactored Channels, Contacts, Map, and Neighbours screens to utilize a new AppBarTitle widget for consistent app bar design.
- Enhanced the BatteryIndicator widget to display SNR information for direct repeaters.
- Introduced SNRUi class for better management of SNR icon and text representation.
- Improved error handling in PathTraceMap and Neighbours screens to log errors appropriately.
* Fix trace route bytes generation logic in Contact model
* Ignore advertisements from self in MeshCoreConnector
* Refactor PathTraceData to use List<double> for snrData and adjust data mapping in PathTraceMapScreen
* Add SNRIndicator to AppBar and refactor BatteryIndicator layout
* Enhance path management dialog to display direct repeaters with color coding based on signal strength
* Remove unused import from SNR indicator widget
* Update lib/models/contact.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/screens/path_trace_map.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/widgets/battery_indicator.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/helpers/cayenne_lpp.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Refactor packet handling to skip only the RSSI byte for improved reliability
* Add SNR indicator localization and update UI references for nearby repeaters
* Handle loading state and error parsing in PathTraceMapScreen; update SNR indicator dialog content layout
* Throw an exception for unsupported LPP types in CayenneLpp class
* Refactor AppBarTitle widget to remove unused style parameter; update related screens to reflect changes
Improve SNR handling by adding validation for spreading factor range in snrUiFromSNR function
Update contact handling in MeshCoreConnector to fix variable naming and improve readability
Stop parsing unsupported LPP types in CayenneLpp to avoid misalignment
* Sort direct repeaters by last updated time and SNR; limit to top three for improved path management dialog
* Prevent notifications for chat and sensor adverts without a valid path
* Implement ranking system for direct repeaters based on SNR and recency; update related UI components to reflect changes
* Refactor localization keys for "neighbors" terminology across multiple languages
- Updated localization keys from "neighbours" to "neighbors" in the following files:
- app_localizations_bg.dart
- app_localizations_de.dart
- app_localizations_en.dart
- app_localizations_es.dart
- app_localizations_fr.dart
- app_localizations_it.dart
- app_localizations_nl.dart
- app_localizations_pl.dart
- app_localizations_pt.dart
- app_localizations_ru.dart
- app_localizations_sk.dart
- app_localizations_sl.dart
- app_localizations_sv.dart
- app_localizations_uk.dart
- app_localizations_zh.dart
- Updated corresponding ARB files to reflect the changes in keys.
- Renamed the NeighboursScreen to NeighborsScreen in the chat and repeater hub screens for consistency.
* Adjust ranking calculation for direct repeaters by adding offset to SNR for improved accuracy
* Fix typo in variable name for second direct repeater in path management dialog
* Refactor ranking calculation for direct repeaters and update path handling in channel message screens
* Refactor path handling in ChannelMessagePathScreen to improve logic for outgoing messages and channel messages
* Fix AppBarTitle horizontal overflow with long titles (#187)
* Initial plan
* Wrap title Column in Expanded to prevent horizontal overflow in AppBarTitle
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
* Refactor AppBarTitle widget to simplify Text widget initialization
* Add "Show All Paths" feature to chat path management
- Implemented localization for "Show All Paths" in multiple languages (DE, EN, ES, FR, IT, NL, PL, PT, RU, SK, SL, SV, UK, ZH).
- Updated path management dialog to include a toggle for showing all paths.
- Refactored path history display logic to conditionally show paths based on the toggle state.
- Cleaned up unused print statements and improved code readability in path tracing and chat screens.
* Refactor FeatureToggleRow visibility in chat and path management dialogs based on repeaters list
* Remove unused import of 'dart:ffi' in path_trace_map.dart
* Refactor repeater management logic and update UI state handling in chat and path management dialogs
* Refactor RX data handling and improve repeater management logic in chat and path management dialogs
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
2026-02-20 20:27:38 -08:00
|
|
|
width: 40,
|
|
|
|
|
height: 40,
|
2026-02-24 19:01:22 -08:00
|
|
|
child: IgnorePointer(
|
|
|
|
|
ignoring: true,
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: const EdgeInsets.all(4),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: Colors.teal,
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
border: Border.all(
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
width: 2,
|
|
|
|
|
),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: Colors.black.withValues(
|
|
|
|
|
alpha: 0.3,
|
|
|
|
|
),
|
|
|
|
|
blurRadius: 4,
|
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: const Icon(
|
|
|
|
|
Icons.person_pin_circle,
|
2026-02-08 18:40:58 -08:00
|
|
|
color: Colors.white,
|
2026-02-24 19:01:22 -08:00
|
|
|
size: 20,
|
2026-02-08 18:40:58 -08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-02-21 01:08:23 -05:00
|
|
|
if (_showNodeLabels &&
|
|
|
|
|
connector.selfLatitude != null &&
|
|
|
|
|
connector.selfLongitude != null)
|
|
|
|
|
_buildNodeLabelMarker(
|
|
|
|
|
point: LatLng(
|
|
|
|
|
connector.selfLatitude!,
|
|
|
|
|
connector.selfLongitude!,
|
|
|
|
|
),
|
2026-02-20 23:41:20 -08:00
|
|
|
label: context.l10n.pathTrace_you,
|
2026-02-21 01:08:23 -05:00
|
|
|
),
|
2026-02-04 08:32:35 -08:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-02-14 00:10:34 -08:00
|
|
|
if (!_isBuildingPathTrace)
|
|
|
|
|
_buildLegend(
|
2026-02-24 19:01:22 -08:00
|
|
|
contacts,
|
2026-02-20 22:09:11 -08:00
|
|
|
contactsWithLocation,
|
|
|
|
|
settings,
|
2026-02-14 00:10:34 -08:00
|
|
|
sharedMarkers.length,
|
2026-03-06 15:02:37 -07:00
|
|
|
guessedLocations.length,
|
2026-02-14 00:10:34 -08:00
|
|
|
),
|
|
|
|
|
if (_isBuildingPathTrace) _buildPathTraceOverlay(),
|
2026-02-04 08:32:35 -08:00
|
|
|
],
|
|
|
|
|
),
|
2025-12-29 20:01:16 -07:00
|
|
|
bottomNavigationBar: SafeArea(
|
|
|
|
|
top: false,
|
|
|
|
|
child: QuickSwitchBar(
|
|
|
|
|
selectedIndex: 2,
|
2026-01-15 19:11:13 -07:00
|
|
|
onDestinationSelected: (index) =>
|
|
|
|
|
_handleQuickSwitch(index, context),
|
2025-12-29 20:01:16 -07:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
floatingActionButton: FloatingActionButton(
|
|
|
|
|
onPressed: () => _showFilterDialog(context, settingsService),
|
2026-01-20 17:21:44 -08:00
|
|
|
tooltip: context.l10n.map_filterNodes,
|
2025-12-29 20:01:16 -07:00
|
|
|
child: const Icon(Icons.filter_list),
|
2025-12-27 15:32:32 -07:00
|
|
|
),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 15:02:37 -07:00
|
|
|
List<_GuessedLocation> _computeGuessedLocations(
|
|
|
|
|
List<Contact> allContacts,
|
|
|
|
|
List<Contact> withLocation,
|
|
|
|
|
PathHistoryService pathHistory,
|
|
|
|
|
double? maxRangeKm,
|
|
|
|
|
) {
|
|
|
|
|
// Index known-location repeaters by their 1-byte hash.
|
|
|
|
|
// null value = two repeaters share the same hash byte (ambiguous collision).
|
|
|
|
|
final repeaterByHash = <int, Contact?>{};
|
2026-02-22 16:00:51 -08:00
|
|
|
|
2026-03-06 15:02:37 -07:00
|
|
|
for (final c in withLocation) {
|
|
|
|
|
if (c.type == advTypeRepeater) {
|
|
|
|
|
if (repeaterByHash.containsKey(c.publicKey[0])) {
|
2026-03-06 15:03:12 -07:00
|
|
|
repeaterByHash[c.publicKey[0]] =
|
|
|
|
|
null; // collision: can't disambiguate
|
2026-03-06 15:02:37 -07:00
|
|
|
} else {
|
|
|
|
|
repeaterByHash[c.publicKey[0]] = c;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final result = <_GuessedLocation>[];
|
|
|
|
|
|
|
|
|
|
for (final contact in allContacts) {
|
|
|
|
|
if (contact.hasLocation) continue;
|
2026-02-22 16:00:51 -08:00
|
|
|
if (contact.lastSeen.isBefore(
|
|
|
|
|
DateTime.now().subtract(const Duration(hours: 24)),
|
|
|
|
|
)) {
|
|
|
|
|
continue; // skip stale contacts
|
|
|
|
|
}
|
2026-03-06 15:02:37 -07:00
|
|
|
|
|
|
|
|
final anchorSet = <LatLng>{};
|
|
|
|
|
|
|
|
|
|
// Collect the contact-side (last-hop) repeater from every known path.
|
|
|
|
|
// path = [device-side hop, ..., contact-side hop]
|
|
|
|
|
// Only path.last is actually within radio range of the contact — using
|
|
|
|
|
// earlier bytes would anchor against our own side of the network.
|
|
|
|
|
final pathSets = <List<int>>[
|
|
|
|
|
contact.path.toList(),
|
|
|
|
|
...pathHistory
|
|
|
|
|
.getRecentPaths(contact.publicKeyHex)
|
|
|
|
|
.map((r) => r.pathBytes),
|
|
|
|
|
];
|
|
|
|
|
final lastHopBytes = <int>{};
|
|
|
|
|
for (final pathBytes in pathSets) {
|
|
|
|
|
if (pathBytes.isEmpty) continue;
|
|
|
|
|
final lastHop = pathBytes.last;
|
|
|
|
|
lastHopBytes.add(lastHop);
|
|
|
|
|
final r = repeaterByHash[lastHop];
|
|
|
|
|
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filter anchors that are geometrically inconsistent with radio range.
|
|
|
|
|
// Two anchors more than 2 * maxRange apart cannot both be in direct radio
|
|
|
|
|
// range of the same node, so isolated outliers are removed.
|
|
|
|
|
final anchors = maxRangeKm != null && anchorSet.length > 1
|
|
|
|
|
? _filterConsistentAnchors(anchorSet.toList(), maxRangeKm)
|
|
|
|
|
: anchorSet.toList();
|
|
|
|
|
|
|
|
|
|
if (anchors.isEmpty) continue;
|
|
|
|
|
|
|
|
|
|
final LatLng position;
|
|
|
|
|
if (anchors.length == 1) {
|
2026-03-20 01:54:31 -07:00
|
|
|
// Spread single-anchor guesses around the anchor so they remain visible.
|
|
|
|
|
position = _offsetGuessedPosition(
|
|
|
|
|
anchors[0],
|
|
|
|
|
contact,
|
|
|
|
|
radiusMeters: 330,
|
2026-03-06 15:02:37 -07:00
|
|
|
);
|
2026-03-12 23:08:46 -07:00
|
|
|
if (!_checkLocationPlausibility(
|
|
|
|
|
position.latitude,
|
|
|
|
|
position.longitude,
|
|
|
|
|
)) {
|
|
|
|
|
continue; // discard implausible guesses near (0, 0)
|
|
|
|
|
}
|
2026-03-06 15:02:37 -07:00
|
|
|
} else {
|
2026-02-22 16:00:51 -08:00
|
|
|
double lat = 0, lon = 0, weight = 1.0;
|
|
|
|
|
int counted = 0;
|
2026-03-06 15:02:37 -07:00
|
|
|
for (final a in anchors) {
|
2026-02-22 16:00:51 -08:00
|
|
|
if (counted == 0) {
|
|
|
|
|
lat = a.latitude;
|
|
|
|
|
lon = a.longitude;
|
|
|
|
|
} else {
|
|
|
|
|
lat += a.latitude * weight;
|
|
|
|
|
lon += a.longitude * weight;
|
|
|
|
|
}
|
|
|
|
|
// weight subsequent anchors less to create a bias towards the first (if more than 2)
|
|
|
|
|
weight = weight / 2;
|
|
|
|
|
counted++;
|
2026-03-06 15:02:37 -07:00
|
|
|
}
|
2026-03-20 01:54:31 -07:00
|
|
|
position = _offsetGuessedPosition(
|
|
|
|
|
LatLng(lat / anchors.length, lon / anchors.length),
|
|
|
|
|
contact,
|
|
|
|
|
radiusMeters: anchors.length >= 3 ? 80 : 120,
|
|
|
|
|
);
|
2026-03-12 23:08:46 -07:00
|
|
|
if (!_checkLocationPlausibility(
|
|
|
|
|
position.latitude,
|
|
|
|
|
position.longitude,
|
|
|
|
|
)) {
|
|
|
|
|
continue; // discard implausible guesses near (0, 0
|
|
|
|
|
}
|
2026-03-06 15:02:37 -07:00
|
|
|
}
|
|
|
|
|
result.add(
|
|
|
|
|
_GuessedLocation(
|
|
|
|
|
contact: contact,
|
|
|
|
|
position: position,
|
|
|
|
|
highConfidence: anchors.length >= 2,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 01:54:31 -07:00
|
|
|
LatLng _offsetGuessedPosition(
|
|
|
|
|
LatLng anchor,
|
|
|
|
|
Contact contact, {
|
|
|
|
|
required double radiusMeters,
|
|
|
|
|
}) {
|
|
|
|
|
final seed = _guessSeed(contact.publicKey);
|
|
|
|
|
final angle = ((seed & 0xFFFF) / 0x10000) * 2 * pi;
|
|
|
|
|
final latOffsetDeg = (radiusMeters / 111320.0) * cos(angle);
|
|
|
|
|
final lonScale = max(cos(anchor.latitude * pi / 180.0).abs(), 0.2);
|
|
|
|
|
final lonOffsetDeg = (radiusMeters / (111320.0 * lonScale)) * sin(angle);
|
|
|
|
|
return LatLng(
|
|
|
|
|
anchor.latitude + latOffsetDeg,
|
|
|
|
|
anchor.longitude + lonOffsetDeg,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int _guessSeed(Uint8List publicKey) {
|
|
|
|
|
var seed = 0x811C9DC5;
|
|
|
|
|
for (final byte in publicKey) {
|
|
|
|
|
seed ^= byte;
|
|
|
|
|
seed = (seed * 0x01000193) & 0x7FFFFFFF;
|
|
|
|
|
}
|
|
|
|
|
return seed;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 15:02:37 -07:00
|
|
|
/// Estimates the free-space maximum LoRa range in km from the connected
|
|
|
|
|
/// device's current radio parameters. Returns null if parameters are unknown.
|
|
|
|
|
double? _estimateLoRaRangeKm(MeshCoreConnector connector) {
|
|
|
|
|
final freqHz = connector.currentFreqHz;
|
|
|
|
|
final bwHz = connector.currentBwHz;
|
|
|
|
|
final sf = connector.currentSf;
|
|
|
|
|
final txPower = connector.currentTxPower;
|
|
|
|
|
if (freqHz == null || bwHz == null || sf == null || txPower == null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
// LoRa receiver sensitivity = thermal noise + NF + required demod SNR
|
|
|
|
|
const noiseFigureDb = 6.0;
|
|
|
|
|
final thermalNoiseDbm = -174.0 + 10 * log(bwHz.toDouble()) / ln10;
|
|
|
|
|
final sensitivityDbm =
|
|
|
|
|
thermalNoiseDbm + noiseFigureDb + _sfToRequiredSnrDb(sf);
|
|
|
|
|
// FSPL at max range equals link budget:
|
|
|
|
|
// FSPL = 20*log10(d_m) + 20*log10(f_hz) - 147.55
|
|
|
|
|
final linkBudgetDb = txPower.toDouble() - sensitivityDbm;
|
|
|
|
|
final exponent =
|
|
|
|
|
(linkBudgetDb + 147.55 - 20 * log(freqHz.toDouble()) / ln10) / 20;
|
|
|
|
|
return pow(10, exponent) / 1000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
double _sfToRequiredSnrDb(int sf) {
|
|
|
|
|
switch (sf) {
|
|
|
|
|
case 5:
|
|
|
|
|
return -2.5;
|
|
|
|
|
case 6:
|
|
|
|
|
return -5.0;
|
|
|
|
|
case 7:
|
|
|
|
|
return -7.5;
|
|
|
|
|
case 8:
|
|
|
|
|
return -10.0;
|
|
|
|
|
case 9:
|
|
|
|
|
return -12.5;
|
|
|
|
|
case 10:
|
|
|
|
|
return -15.0;
|
|
|
|
|
case 11:
|
|
|
|
|
return -17.5;
|
|
|
|
|
case 12:
|
|
|
|
|
return -20.0;
|
|
|
|
|
default:
|
|
|
|
|
return -10.0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Removes anchors that have no neighbour within 2 * maxRangeKm.
|
|
|
|
|
/// A node cannot be simultaneously in radio range of two points farther apart
|
|
|
|
|
/// than twice the expected maximum range.
|
|
|
|
|
List<LatLng> _filterConsistentAnchors(
|
|
|
|
|
List<LatLng> anchors,
|
|
|
|
|
double maxRangeKm,
|
|
|
|
|
) {
|
|
|
|
|
const distance = Distance();
|
|
|
|
|
final maxDistM = maxRangeKm * 2000;
|
|
|
|
|
return anchors
|
2026-03-06 15:03:12 -07:00
|
|
|
.where((a) => anchors.any((b) => b != a && distance(a, b) <= maxDistM))
|
2026-03-06 15:02:37 -07:00
|
|
|
.toList();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 23:08:46 -07:00
|
|
|
List<Marker> _buildGuessedMarker(
|
|
|
|
|
List<_GuessedLocation> guessed, {
|
|
|
|
|
required bool showLabels,
|
|
|
|
|
}) {
|
|
|
|
|
final markers = <Marker>[];
|
|
|
|
|
|
|
|
|
|
for (final guess in guessed) {
|
2026-03-24 17:45:54 -07:00
|
|
|
if (guess.contact.type == advTypeChat && _isBuildingPathTrace) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 23:08:46 -07:00
|
|
|
final color = _getNodeColor(guess.contact.type);
|
|
|
|
|
final marker = Marker(
|
|
|
|
|
point: guess.position,
|
|
|
|
|
width: 35,
|
|
|
|
|
height: 35,
|
|
|
|
|
child: GestureDetector(
|
2026-03-24 17:45:54 -07:00
|
|
|
onLongPress: () => _isBuildingPathTrace
|
|
|
|
|
? _showNodeInfo(context, guess.contact)
|
|
|
|
|
: null,
|
|
|
|
|
onTap: () => _isBuildingPathTrace
|
|
|
|
|
? _addToPath(context, guess.contact, position: guess.position)
|
|
|
|
|
: _showNodeInfo(
|
|
|
|
|
context,
|
|
|
|
|
guess.contact,
|
|
|
|
|
guessedPosition: guess.position,
|
|
|
|
|
),
|
2026-03-12 23:08:46 -07:00
|
|
|
child: Container(
|
|
|
|
|
padding: const EdgeInsets.all(4),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: color.withValues(
|
|
|
|
|
alpha: guess.highConfidence ? 0.55 : 0.30,
|
|
|
|
|
),
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
|
|
|
blurRadius: 4,
|
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
child: const Icon(
|
|
|
|
|
Icons.not_listed_location,
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
size: 20,
|
|
|
|
|
),
|
2026-03-06 15:02:37 -07:00
|
|
|
),
|
|
|
|
|
),
|
2026-03-12 23:08:46 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
markers.add(marker);
|
|
|
|
|
|
|
|
|
|
if (showLabels) {
|
|
|
|
|
markers.add(
|
|
|
|
|
_buildNodeLabelMarker(
|
|
|
|
|
point: guess.position,
|
|
|
|
|
label: guess.contact.name,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return markers;
|
2026-03-06 15:02:37 -07:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 16:00:51 -08:00
|
|
|
List<Contact> _filterContactsBySettings(
|
2026-02-21 01:08:23 -05:00
|
|
|
List<Contact> contacts,
|
2026-02-24 19:01:22 -08:00
|
|
|
dynamic settings, {
|
|
|
|
|
bool noLocations = false,
|
|
|
|
|
}) {
|
2026-02-22 16:00:51 -08:00
|
|
|
List<Contact> filtered = [];
|
|
|
|
|
bool addContact = false;
|
2025-12-26 11:42:02 -07:00
|
|
|
for (final contact in contacts) {
|
2026-02-22 16:00:51 -08:00
|
|
|
addContact = false;
|
2026-02-24 19:01:22 -08:00
|
|
|
if (!contact.hasLocation && !noLocations) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
// Apply node type filters
|
2026-02-20 22:09:11 -08:00
|
|
|
if (contact.type == advTypeRepeater &&
|
2026-02-22 16:00:51 -08:00
|
|
|
(settings.mapShowRepeaters ||
|
|
|
|
|
_isBuildingPathTrace ||
|
|
|
|
|
settings.mapShowOverlaps)) {
|
|
|
|
|
addContact = true;
|
2026-02-20 22:09:11 -08:00
|
|
|
}
|
|
|
|
|
if (contact.type == advTypeChat &&
|
2026-02-22 16:00:51 -08:00
|
|
|
(settings.mapShowChatNodes || _isBuildingPathTrace)) {
|
|
|
|
|
addContact = true;
|
2026-01-17 11:00:34 -05:00
|
|
|
}
|
2025-12-26 11:42:02 -07:00
|
|
|
if (contact.type != advTypeChat &&
|
|
|
|
|
contact.type != advTypeRepeater &&
|
2026-02-22 16:00:51 -08:00
|
|
|
(settings.mapShowOtherNodes ||
|
|
|
|
|
_isBuildingPathTrace ||
|
|
|
|
|
settings.mapShowOverlaps)) {
|
|
|
|
|
addContact = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 16:46:01 -07:00
|
|
|
if (contact.type == advTypeChat && _isBuildingPathTrace) {
|
2026-02-22 16:00:51 -08:00
|
|
|
addContact = false;
|
2025-12-26 11:42:02 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-26 16:46:01 -07:00
|
|
|
if (settings.mapShowOverlaps) {
|
|
|
|
|
final hasOverlap = contacts
|
|
|
|
|
.where(
|
|
|
|
|
(c) =>
|
|
|
|
|
c.publicKeyHex != contact.publicKeyHex &&
|
|
|
|
|
c.publicKey.first == contact.publicKey.first &&
|
|
|
|
|
(c.type == advTypeRepeater || c.type == advTypeRoom) &&
|
|
|
|
|
(contact.type == advTypeRepeater ||
|
|
|
|
|
contact.type == advTypeRoom),
|
|
|
|
|
)
|
|
|
|
|
.firstOrNull;
|
|
|
|
|
|
|
|
|
|
if (hasOverlap == null &&
|
|
|
|
|
settings.mapShowOverlaps &&
|
|
|
|
|
!_isBuildingPathTrace) {
|
|
|
|
|
addContact = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 16:00:51 -08:00
|
|
|
if (addContact) {
|
|
|
|
|
filtered.add(contact);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return filtered;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<Marker> _buildMarkers(
|
|
|
|
|
List<Contact> contacts,
|
|
|
|
|
settings, {
|
|
|
|
|
required bool showLabels,
|
|
|
|
|
}) {
|
|
|
|
|
final markers = <Marker>[];
|
|
|
|
|
final filteredContacts = _filterContactsBySettings(contacts, settings);
|
|
|
|
|
for (final contact in filteredContacts) {
|
2025-12-26 11:42:02 -07:00
|
|
|
final marker = Marker(
|
|
|
|
|
point: LatLng(contact.latitude!, contact.longitude!),
|
2026-02-08 18:40:58 -08:00
|
|
|
width: 35,
|
|
|
|
|
height: 35,
|
2025-12-26 11:42:02 -07:00
|
|
|
child: GestureDetector(
|
2026-02-14 00:10:34 -08:00
|
|
|
onLongPress: () =>
|
|
|
|
|
_isBuildingPathTrace ? _showNodeInfo(context, contact) : null,
|
|
|
|
|
onTap: () => _isBuildingPathTrace
|
|
|
|
|
? _addToPath(context, contact)
|
|
|
|
|
: _showNodeInfo(context, contact),
|
2025-12-26 11:42:02 -07:00
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
2026-02-08 18:40:58 -08:00
|
|
|
padding: const EdgeInsets.all(4),
|
2025-12-26 11:42:02 -07:00
|
|
|
decoration: BoxDecoration(
|
2026-02-22 16:00:51 -08:00
|
|
|
color: settings.mapShowOverlaps && !_isBuildingPathTrace
|
|
|
|
|
? Colors.red
|
|
|
|
|
: _getNodeColor(contact.type),
|
2025-12-26 11:42:02 -07:00
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
|
|
|
blurRadius: 4,
|
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
child: Icon(
|
|
|
|
|
_getNodeIcon(contact.type),
|
|
|
|
|
color: Colors.white,
|
2026-02-08 18:40:58 -08:00
|
|
|
size: 20,
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
markers.add(marker);
|
2026-02-21 01:08:23 -05:00
|
|
|
if (showLabels) {
|
|
|
|
|
markers.add(
|
|
|
|
|
_buildNodeLabelMarker(
|
|
|
|
|
point: LatLng(contact.latitude!, contact.longitude!),
|
2026-02-22 16:00:51 -08:00
|
|
|
label: settings.mapShowOverlaps && !_isBuildingPathTrace
|
|
|
|
|
? "${contact.publicKeyHex.substring(0, 2)}:${contact.name}"
|
|
|
|
|
: contact.name,
|
2026-02-21 01:08:23 -05:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-12-26 11:42:02 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return markers;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 01:08:23 -05:00
|
|
|
Marker _buildNodeLabelMarker({required LatLng point, required String label}) {
|
|
|
|
|
return Marker(
|
|
|
|
|
point: point,
|
|
|
|
|
width: 120,
|
|
|
|
|
height: 24,
|
|
|
|
|
alignment: Alignment.topCenter,
|
|
|
|
|
child: IgnorePointer(
|
|
|
|
|
child: Transform.translate(
|
2026-02-20 23:41:20 -08:00
|
|
|
offset: const Offset(0, -20),
|
|
|
|
|
child: FittedBox(
|
|
|
|
|
fit: BoxFit.contain,
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: Colors.black54,
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
),
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: Text(
|
|
|
|
|
label,
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
fontWeight: FontWeight.w500,
|
2026-02-21 01:08:23 -05:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 11:42:02 -07:00
|
|
|
Color _getNodeColor(int type) {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case advTypeChat:
|
|
|
|
|
return Colors.blue;
|
|
|
|
|
case advTypeRepeater:
|
|
|
|
|
return Colors.green;
|
|
|
|
|
case advTypeRoom:
|
|
|
|
|
return Colors.purple;
|
|
|
|
|
case advTypeSensor:
|
|
|
|
|
return Colors.orange;
|
|
|
|
|
default:
|
|
|
|
|
return Colors.grey;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
IconData _getNodeIcon(int type) {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case advTypeChat:
|
|
|
|
|
return Icons.person;
|
|
|
|
|
case advTypeRepeater:
|
|
|
|
|
return Icons.router;
|
|
|
|
|
case advTypeRoom:
|
|
|
|
|
return Icons.meeting_room;
|
|
|
|
|
case advTypeSensor:
|
|
|
|
|
return Icons.sensors;
|
|
|
|
|
default:
|
|
|
|
|
return Icons.device_unknown;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 22:09:11 -08:00
|
|
|
Widget _buildLegend(
|
2026-02-24 19:01:22 -08:00
|
|
|
List<Contact> contacts,
|
2026-02-20 22:09:11 -08:00
|
|
|
List<Contact> contactsWithLocation,
|
|
|
|
|
settings,
|
|
|
|
|
int markerCount,
|
2026-03-06 15:02:37 -07:00
|
|
|
int guessedCount,
|
2026-02-20 22:09:11 -08:00
|
|
|
) {
|
2026-02-22 16:00:51 -08:00
|
|
|
final filteredContacts = _filterContactsBySettings(
|
2026-02-24 19:01:22 -08:00
|
|
|
contacts,
|
2026-02-22 16:00:51 -08:00
|
|
|
settings,
|
2026-02-24 19:01:22 -08:00
|
|
|
noLocations: false,
|
|
|
|
|
);
|
|
|
|
|
final filteredContactsAll = _filterContactsBySettings(
|
|
|
|
|
contacts,
|
|
|
|
|
settings,
|
|
|
|
|
noLocations: true,
|
2026-02-22 16:00:51 -08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final nodeCount = filteredContacts.length;
|
2026-02-24 19:01:22 -08:00
|
|
|
final nodeCountAll = filteredContactsAll.length;
|
2026-02-20 22:09:11 -08:00
|
|
|
|
2025-12-26 11:42:02 -07:00
|
|
|
return Positioned(
|
|
|
|
|
top: 16,
|
|
|
|
|
right: 16,
|
|
|
|
|
child: Card(
|
2026-02-12 15:46:28 -05:00
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
InkWell(
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
onTap: () {
|
|
|
|
|
setState(() {
|
|
|
|
|
_legendExpanded = !_legendExpanded;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
2026-03-06 15:02:37 -07:00
|
|
|
context.l10n.map_nodesCount(
|
|
|
|
|
nodeCount +
|
|
|
|
|
(settings.mapShowGuessedLocations
|
|
|
|
|
? guessedCount
|
|
|
|
|
: 0),
|
|
|
|
|
),
|
2026-02-12 15:46:28 -05:00
|
|
|
style: const TextStyle(
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-02-24 19:01:22 -08:00
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Icon(
|
|
|
|
|
Icons.location_on,
|
|
|
|
|
size: 16,
|
|
|
|
|
color: Colors.grey,
|
|
|
|
|
),
|
|
|
|
|
Text(
|
|
|
|
|
": $nodeCount",
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
const Icon(
|
|
|
|
|
Icons.wrong_location,
|
|
|
|
|
size: 16,
|
|
|
|
|
color: Colors.grey,
|
|
|
|
|
),
|
|
|
|
|
Text(
|
|
|
|
|
": ${nodeCountAll - nodeCount}",
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
const Icon(
|
|
|
|
|
Icons.add_outlined,
|
|
|
|
|
size: 16,
|
|
|
|
|
color: Colors.grey,
|
|
|
|
|
),
|
|
|
|
|
Text(
|
|
|
|
|
": $nodeCountAll",
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-02-12 15:46:28 -05:00
|
|
|
Text(
|
|
|
|
|
context.l10n.map_pinsCount(markerCount),
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
AnimatedRotation(
|
|
|
|
|
turns: _legendExpanded ? 0.5 : 0,
|
|
|
|
|
duration: const Duration(milliseconds: 200),
|
|
|
|
|
child: const Icon(Icons.expand_more, size: 20),
|
|
|
|
|
),
|
|
|
|
|
],
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
),
|
2026-02-12 15:46:28 -05:00
|
|
|
),
|
|
|
|
|
AnimatedCrossFade(
|
|
|
|
|
firstChild: const SizedBox.shrink(),
|
|
|
|
|
secondChild: Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
_buildLegendItem(
|
|
|
|
|
Icons.person,
|
|
|
|
|
context.l10n.map_chat,
|
|
|
|
|
Colors.blue,
|
|
|
|
|
),
|
|
|
|
|
_buildLegendItem(
|
|
|
|
|
Icons.router,
|
|
|
|
|
context.l10n.map_repeater,
|
|
|
|
|
Colors.green,
|
|
|
|
|
),
|
|
|
|
|
_buildLegendItem(
|
|
|
|
|
Icons.meeting_room,
|
|
|
|
|
context.l10n.map_room,
|
|
|
|
|
Colors.purple,
|
|
|
|
|
),
|
|
|
|
|
_buildLegendItem(
|
|
|
|
|
Icons.sensors,
|
|
|
|
|
context.l10n.map_sensor,
|
|
|
|
|
Colors.orange,
|
|
|
|
|
),
|
|
|
|
|
_buildLegendItem(
|
|
|
|
|
Icons.flag,
|
|
|
|
|
context.l10n.map_pinDm,
|
|
|
|
|
Colors.blue,
|
|
|
|
|
),
|
|
|
|
|
_buildLegendItem(
|
|
|
|
|
Icons.flag,
|
|
|
|
|
context.l10n.map_pinPrivate,
|
|
|
|
|
Colors.purple,
|
|
|
|
|
),
|
|
|
|
|
_buildLegendItem(
|
|
|
|
|
Icons.flag,
|
|
|
|
|
context.l10n.map_pinPublic,
|
|
|
|
|
Colors.orange,
|
|
|
|
|
),
|
2026-03-06 15:02:37 -07:00
|
|
|
if (settings.mapShowGuessedLocations && guessedCount > 0)
|
|
|
|
|
_buildLegendItem(
|
|
|
|
|
Icons.not_listed_location,
|
|
|
|
|
context.l10n.map_guessedLocation,
|
|
|
|
|
Colors.grey,
|
|
|
|
|
),
|
2026-02-12 15:46:28 -05:00
|
|
|
],
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
),
|
2026-02-12 15:46:28 -05:00
|
|
|
crossFadeState: _legendExpanded
|
|
|
|
|
? CrossFadeState.showSecond
|
|
|
|
|
: CrossFadeState.showFirst,
|
|
|
|
|
duration: const Duration(milliseconds: 200),
|
|
|
|
|
),
|
|
|
|
|
],
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildLegendItem(IconData icon, String label, Color color) {
|
|
|
|
|
return Padding(
|
2026-02-14 00:10:34 -08:00
|
|
|
padding: const EdgeInsets.symmetric(vertical: 1.0),
|
2025-12-26 11:42:02 -07:00
|
|
|
child: Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(icon, size: 16, color: color),
|
|
|
|
|
const SizedBox(width: 8),
|
2026-01-15 19:11:13 -07:00
|
|
|
Text(label, style: const TextStyle(fontSize: 12)),
|
2025-12-26 11:42:02 -07:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) {
|
|
|
|
|
final markers = <_SharedMarker>[];
|
|
|
|
|
final selfName = connector.selfName ?? 'Me';
|
|
|
|
|
|
|
|
|
|
for (final contact in connector.contacts) {
|
|
|
|
|
final messages = connector.getMessages(contact);
|
|
|
|
|
for (final message in messages) {
|
|
|
|
|
final payload = _parseMarkerText(message.text);
|
|
|
|
|
if (payload == null) continue;
|
|
|
|
|
final fromName = message.isOutgoing ? selfName : contact.name;
|
|
|
|
|
final id = _buildMarkerId(
|
|
|
|
|
sourceId: contact.publicKeyHex,
|
|
|
|
|
timestamp: message.timestamp,
|
|
|
|
|
text: message.text,
|
|
|
|
|
);
|
|
|
|
|
markers.add(
|
|
|
|
|
_SharedMarker(
|
|
|
|
|
id: id,
|
|
|
|
|
position: payload.position,
|
|
|
|
|
label: payload.label,
|
|
|
|
|
flags: payload.flags,
|
|
|
|
|
fromName: fromName,
|
|
|
|
|
sourceLabel: contact.name,
|
|
|
|
|
isChannel: false,
|
|
|
|
|
isPublicChannel: false,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (final channel in connector.channels.where((c) => !c.isEmpty)) {
|
|
|
|
|
final isPublic = _isPublicChannel(channel);
|
|
|
|
|
final messages = connector.getChannelMessages(channel);
|
|
|
|
|
for (final message in messages) {
|
|
|
|
|
final payload = _parseMarkerText(message.text);
|
|
|
|
|
if (payload == null) continue;
|
|
|
|
|
final id = _buildMarkerId(
|
|
|
|
|
sourceId: 'channel:${channel.index}',
|
|
|
|
|
timestamp: message.timestamp,
|
|
|
|
|
text: message.text,
|
|
|
|
|
);
|
|
|
|
|
markers.add(
|
|
|
|
|
_SharedMarker(
|
|
|
|
|
id: id,
|
|
|
|
|
position: payload.position,
|
|
|
|
|
label: payload.label,
|
|
|
|
|
flags: payload.flags,
|
|
|
|
|
fromName: message.senderName,
|
2026-01-15 19:11:13 -07:00
|
|
|
sourceLabel: channel.name.isEmpty
|
|
|
|
|
? 'Channel ${channel.index}'
|
|
|
|
|
: channel.name,
|
2025-12-26 11:42:02 -07:00
|
|
|
isChannel: true,
|
|
|
|
|
isPublicChannel: isPublic,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return markers;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_MarkerPayload? _parseMarkerText(String text) {
|
|
|
|
|
final trimmed = text.trim();
|
|
|
|
|
if (!trimmed.startsWith('m:')) return null;
|
|
|
|
|
|
|
|
|
|
final parts = trimmed.substring(2).split('|');
|
|
|
|
|
if (parts.isEmpty) return null;
|
|
|
|
|
final coords = parts[0].split(',');
|
|
|
|
|
if (coords.length != 2) return null;
|
|
|
|
|
final lat = double.tryParse(coords[0].trim());
|
|
|
|
|
final lon = double.tryParse(coords[1].trim());
|
|
|
|
|
if (lat == null || lon == null) return null;
|
|
|
|
|
|
|
|
|
|
final label = parts.length > 1 ? parts[1].trim() : '';
|
|
|
|
|
final flags = parts.length > 2 ? parts[2].trim() : '';
|
|
|
|
|
return _MarkerPayload(
|
|
|
|
|
position: LatLng(lat, lon),
|
2026-01-11 17:13:50 -07:00
|
|
|
label: label.isEmpty ? context.l10n.map_sharedPin : label,
|
2025-12-26 11:42:02 -07:00
|
|
|
flags: flags,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _buildMarkerId({
|
|
|
|
|
required String sourceId,
|
|
|
|
|
required DateTime timestamp,
|
|
|
|
|
required String text,
|
|
|
|
|
}) {
|
|
|
|
|
return '$sourceId|${timestamp.millisecondsSinceEpoch}|$text';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Marker _buildSharedMarker(_SharedMarker marker) {
|
|
|
|
|
final markerColor = marker.isChannel
|
|
|
|
|
? (marker.isPublicChannel ? Colors.orange : Colors.purple)
|
|
|
|
|
: Colors.blue;
|
|
|
|
|
return Marker(
|
|
|
|
|
point: marker.position,
|
|
|
|
|
width: 60,
|
|
|
|
|
height: 60,
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
onTap: () => _showMarkerInfo(marker),
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
|
|
|
|
padding: const EdgeInsets.all(6),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: markerColor,
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
|
|
|
blurRadius: 4,
|
|
|
|
|
offset: const Offset(0, 2),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-01-15 19:11:13 -07:00
|
|
|
child: const Icon(Icons.flag, color: Colors.white, size: 20),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 16:41:46 -08:00
|
|
|
void _showRepeaterLogin(BuildContext context, Contact repeater) {
|
|
|
|
|
showDialog(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (context) => RepeaterLoginDialog(
|
|
|
|
|
repeater: repeater,
|
|
|
|
|
onLogin: (password) {
|
|
|
|
|
// Navigate to repeater hub screen after successful login
|
|
|
|
|
Navigator.push(
|
|
|
|
|
context,
|
|
|
|
|
MaterialPageRoute(
|
2026-01-15 19:11:13 -07:00
|
|
|
builder: (context) =>
|
|
|
|
|
RepeaterHubScreen(repeater: repeater, password: password),
|
2026-01-05 16:41:46 -08:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 11:51:40 -07:00
|
|
|
void _showRoomLogin(BuildContext context, Contact room) {
|
|
|
|
|
showDialog(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (context) => RoomLoginDialog(
|
|
|
|
|
room: room,
|
|
|
|
|
onLogin: (password) {
|
|
|
|
|
// Navigate to chat screen after successful login
|
|
|
|
|
context.read<MeshCoreConnector>().markContactRead(room.publicKeyHex);
|
|
|
|
|
Navigator.push(
|
|
|
|
|
context,
|
2026-01-15 19:11:13 -07:00
|
|
|
MaterialPageRoute(builder: (context) => ChatScreen(contact: room)),
|
2026-01-11 11:51:40 -07:00
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 15:02:37 -07:00
|
|
|
void _showNodeInfo(
|
|
|
|
|
BuildContext context,
|
|
|
|
|
Contact contact, {
|
|
|
|
|
LatLng? guessedPosition,
|
|
|
|
|
}) {
|
2026-03-12 23:08:46 -07:00
|
|
|
final connector = context.read<MeshCoreConnector>();
|
2025-12-26 11:42:02 -07:00
|
|
|
showDialog(
|
|
|
|
|
context: context,
|
2026-01-11 17:13:50 -07:00
|
|
|
builder: (dialogContext) => AlertDialog(
|
2025-12-26 11:42:02 -07:00
|
|
|
title: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Icon(
|
|
|
|
|
_getNodeIcon(contact.type),
|
|
|
|
|
color: _getNodeColor(contact.type),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
Add a signal readout for the nearest repeater. With improvements to app bar and other UI polish. (#200)
* Refactor Cayenne LPP parsing with error handling and logging
- Added error handling and logging to the Cayenne LPP parsing methods to manage malformed data gracefully.
- Improved the structure of the parsing logic for better readability and maintainability.
- Updated the Contact model to include error handling during frame parsing.
- Refactored Channels, Contacts, Map, and Neighbours screens to utilize a new AppBarTitle widget for consistent app bar design.
- Enhanced the BatteryIndicator widget to display SNR information for direct repeaters.
- Introduced SNRUi class for better management of SNR icon and text representation.
- Improved error handling in PathTraceMap and Neighbours screens to log errors appropriately.
* Fix trace route bytes generation logic in Contact model
* Ignore advertisements from self in MeshCoreConnector
* Refactor PathTraceData to use List<double> for snrData and adjust data mapping in PathTraceMapScreen
* Add SNRIndicator to AppBar and refactor BatteryIndicator layout
* Enhance path management dialog to display direct repeaters with color coding based on signal strength
* Remove unused import from SNR indicator widget
* Update lib/models/contact.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/screens/path_trace_map.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/widgets/battery_indicator.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/helpers/cayenne_lpp.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Refactor packet handling to skip only the RSSI byte for improved reliability
* Add SNR indicator localization and update UI references for nearby repeaters
* Handle loading state and error parsing in PathTraceMapScreen; update SNR indicator dialog content layout
* Throw an exception for unsupported LPP types in CayenneLpp class
* Refactor AppBarTitle widget to remove unused style parameter; update related screens to reflect changes
Improve SNR handling by adding validation for spreading factor range in snrUiFromSNR function
Update contact handling in MeshCoreConnector to fix variable naming and improve readability
Stop parsing unsupported LPP types in CayenneLpp to avoid misalignment
* Sort direct repeaters by last updated time and SNR; limit to top three for improved path management dialog
* Prevent notifications for chat and sensor adverts without a valid path
* Implement ranking system for direct repeaters based on SNR and recency; update related UI components to reflect changes
* Refactor localization keys for "neighbors" terminology across multiple languages
- Updated localization keys from "neighbours" to "neighbors" in the following files:
- app_localizations_bg.dart
- app_localizations_de.dart
- app_localizations_en.dart
- app_localizations_es.dart
- app_localizations_fr.dart
- app_localizations_it.dart
- app_localizations_nl.dart
- app_localizations_pl.dart
- app_localizations_pt.dart
- app_localizations_ru.dart
- app_localizations_sk.dart
- app_localizations_sl.dart
- app_localizations_sv.dart
- app_localizations_uk.dart
- app_localizations_zh.dart
- Updated corresponding ARB files to reflect the changes in keys.
- Renamed the NeighboursScreen to NeighborsScreen in the chat and repeater hub screens for consistency.
* Adjust ranking calculation for direct repeaters by adding offset to SNR for improved accuracy
* Fix typo in variable name for second direct repeater in path management dialog
* Refactor ranking calculation for direct repeaters and update path handling in channel message screens
* Refactor path handling in ChannelMessagePathScreen to improve logic for outgoing messages and channel messages
* Fix AppBarTitle horizontal overflow with long titles (#187)
* Initial plan
* Wrap title Column in Expanded to prevent horizontal overflow in AppBarTitle
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
* Refactor AppBarTitle widget to simplify Text widget initialization
* Add "Show All Paths" feature to chat path management
- Implemented localization for "Show All Paths" in multiple languages (DE, EN, ES, FR, IT, NL, PL, PT, RU, SK, SL, SV, UK, ZH).
- Updated path management dialog to include a toggle for showing all paths.
- Refactored path history display logic to conditionally show paths based on the toggle state.
- Cleaned up unused print statements and improved code readability in path tracing and chat screens.
* Refactor FeatureToggleRow visibility in chat and path management dialogs based on repeaters list
* Remove unused import of 'dart:ffi' in path_trace_map.dart
* Refactor repeater management logic and update UI state handling in chat and path management dialogs
* Refactor RX data handling and improve repeater management logic in chat and path management dialogs
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
2026-02-20 20:27:38 -08:00
|
|
|
Expanded(child: SelectableText(contact.name)),
|
2025-12-26 11:42:02 -07:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
content: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
_buildInfoRow('Type', contact.typeLabel),
|
|
|
|
|
_buildInfoRow('Path', contact.pathLabel),
|
2026-03-06 15:02:37 -07:00
|
|
|
if (contact.hasLocation)
|
|
|
|
|
_buildInfoRow(
|
|
|
|
|
'Location',
|
|
|
|
|
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
|
|
|
|
|
)
|
|
|
|
|
else if (guessedPosition != null)
|
|
|
|
|
_buildInfoRow(
|
|
|
|
|
'Est. Location',
|
|
|
|
|
'~${guessedPosition.latitude.toStringAsFixed(6)}, ${guessedPosition.longitude.toStringAsFixed(6)}',
|
|
|
|
|
),
|
2026-01-15 19:11:13 -07:00
|
|
|
_buildInfoRow(
|
|
|
|
|
context.l10n.map_lastSeen,
|
|
|
|
|
_formatLastSeen(contact.lastSeen),
|
|
|
|
|
),
|
2025-12-26 11:42:02 -07:00
|
|
|
_buildInfoRow('Public Key', contact.publicKeyHex),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
2026-01-11 17:13:50 -07:00
|
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
|
|
|
child: Text(context.l10n.common_close),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
2026-01-15 19:11:13 -07:00
|
|
|
if (contact.type ==
|
|
|
|
|
advTypeChat) // Only show chat button for chat nodes
|
2025-12-26 11:42:02 -07:00
|
|
|
TextButton(
|
|
|
|
|
onPressed: () {
|
2026-03-12 23:08:46 -07:00
|
|
|
if (!contact.isActive) {
|
|
|
|
|
connector.importDiscoveredContact(contact);
|
|
|
|
|
}
|
2026-01-11 17:13:50 -07:00
|
|
|
Navigator.pop(dialogContext);
|
2025-12-26 11:42:02 -07:00
|
|
|
Navigator.push(
|
|
|
|
|
context,
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (context) => ChatScreen(contact: contact),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-01-11 17:13:50 -07:00
|
|
|
child: Text(context.l10n.contacts_openChat),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
2026-01-15 19:11:13 -07:00
|
|
|
if (contact.type == advTypeRepeater)
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () {
|
2026-03-12 23:08:46 -07:00
|
|
|
if (!contact.isActive) {
|
|
|
|
|
connector.importDiscoveredContact(contact);
|
|
|
|
|
}
|
2026-01-15 19:11:13 -07:00
|
|
|
Navigator.pop(dialogContext);
|
|
|
|
|
_showRepeaterLogin(context, contact);
|
|
|
|
|
},
|
|
|
|
|
child: Text(context.l10n.map_manageRepeater),
|
|
|
|
|
),
|
|
|
|
|
if (contact.type == advTypeRoom)
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () {
|
2026-03-12 23:08:46 -07:00
|
|
|
if (!contact.isActive) {
|
|
|
|
|
connector.importDiscoveredContact(contact);
|
|
|
|
|
}
|
2026-01-15 19:11:13 -07:00
|
|
|
Navigator.pop(dialogContext);
|
|
|
|
|
_showRoomLogin(context, contact);
|
|
|
|
|
},
|
|
|
|
|
child: Text(context.l10n.map_joinRoom),
|
|
|
|
|
),
|
2025-12-26 11:42:02 -07:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-27 15:32:32 -07:00
|
|
|
void _handleQuickSwitch(int index, BuildContext context) {
|
|
|
|
|
if (index == 2) return;
|
|
|
|
|
switch (index) {
|
|
|
|
|
case 0:
|
|
|
|
|
Navigator.pushReplacement(
|
|
|
|
|
context,
|
2026-01-15 19:11:13 -07:00
|
|
|
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
|
2025-12-27 15:32:32 -07:00
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
case 1:
|
|
|
|
|
Navigator.pushReplacement(
|
|
|
|
|
context,
|
2026-01-15 19:11:13 -07:00
|
|
|
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
|
2025-12-27 15:32:32 -07:00
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _disconnect(
|
|
|
|
|
BuildContext context,
|
|
|
|
|
MeshCoreConnector connector,
|
|
|
|
|
) async {
|
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
|
|
|
context: context,
|
2026-01-11 17:13:50 -07:00
|
|
|
builder: (dialogContext) => AlertDialog(
|
|
|
|
|
title: Text(context.l10n.common_disconnect),
|
|
|
|
|
content: Text(context.l10n.map_disconnectConfirm),
|
2025-12-27 15:32:32 -07:00
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
2026-01-11 17:13:50 -07:00
|
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
|
|
|
child: Text(context.l10n.common_cancel),
|
2025-12-27 15:32:32 -07:00
|
|
|
),
|
|
|
|
|
TextButton(
|
2026-01-11 17:13:50 -07:00
|
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
|
|
|
child: Text(context.l10n.common_disconnect),
|
2025-12-27 15:32:32 -07:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (confirmed == true) {
|
|
|
|
|
await connector.disconnect();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 11:42:02 -07:00
|
|
|
void _showMarkerInfo(_SharedMarker marker) {
|
|
|
|
|
showDialog(
|
|
|
|
|
context: context,
|
2026-01-11 17:13:50 -07:00
|
|
|
builder: (dialogContext) => AlertDialog(
|
2025-12-26 11:42:02 -07:00
|
|
|
title: Text(marker.label),
|
|
|
|
|
content: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
2026-01-11 17:13:50 -07:00
|
|
|
_buildInfoRow(context.l10n.map_from, marker.fromName),
|
|
|
|
|
_buildInfoRow(context.l10n.map_source, marker.sourceLabel),
|
2025-12-26 11:42:02 -07:00
|
|
|
_buildInfoRow(
|
|
|
|
|
'Location',
|
|
|
|
|
'${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}',
|
|
|
|
|
),
|
2026-01-15 19:11:13 -07:00
|
|
|
if (marker.flags.isNotEmpty)
|
|
|
|
|
_buildInfoRow(context.l10n.map_flags, marker.flags),
|
2025-12-26 11:42:02 -07:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
setState(() {
|
|
|
|
|
_hiddenMarkerIds.add(marker.id);
|
|
|
|
|
});
|
2026-01-11 17:13:50 -07:00
|
|
|
Navigator.pop(dialogContext);
|
2025-12-26 11:42:02 -07:00
|
|
|
},
|
2026-01-11 17:13:50 -07:00
|
|
|
child: Text(context.l10n.common_hide),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () async {
|
|
|
|
|
setState(() {
|
|
|
|
|
_hiddenMarkerIds.add(marker.id);
|
|
|
|
|
_removedMarkerIds.add(marker.id);
|
|
|
|
|
});
|
|
|
|
|
await _markerService.saveRemovedIds(_removedMarkerIds);
|
2026-01-11 17:13:50 -07:00
|
|
|
if (dialogContext.mounted) {
|
|
|
|
|
Navigator.pop(dialogContext);
|
2025-12-26 11:42:02 -07:00
|
|
|
}
|
|
|
|
|
},
|
2026-01-11 17:13:50 -07:00
|
|
|
child: Text(context.l10n.common_remove),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
TextButton(
|
2026-01-11 17:13:50 -07:00
|
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
|
|
|
child: Text(context.l10n.common_close),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildInfoRow(String label, String value) {
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
label,
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
color: Colors.grey[600],
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 2),
|
Add a signal readout for the nearest repeater. With improvements to app bar and other UI polish. (#200)
* Refactor Cayenne LPP parsing with error handling and logging
- Added error handling and logging to the Cayenne LPP parsing methods to manage malformed data gracefully.
- Improved the structure of the parsing logic for better readability and maintainability.
- Updated the Contact model to include error handling during frame parsing.
- Refactored Channels, Contacts, Map, and Neighbours screens to utilize a new AppBarTitle widget for consistent app bar design.
- Enhanced the BatteryIndicator widget to display SNR information for direct repeaters.
- Introduced SNRUi class for better management of SNR icon and text representation.
- Improved error handling in PathTraceMap and Neighbours screens to log errors appropriately.
* Fix trace route bytes generation logic in Contact model
* Ignore advertisements from self in MeshCoreConnector
* Refactor PathTraceData to use List<double> for snrData and adjust data mapping in PathTraceMapScreen
* Add SNRIndicator to AppBar and refactor BatteryIndicator layout
* Enhance path management dialog to display direct repeaters with color coding based on signal strength
* Remove unused import from SNR indicator widget
* Update lib/models/contact.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/connector/meshcore_connector.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/screens/path_trace_map.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/widgets/battery_indicator.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update lib/helpers/cayenne_lpp.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Refactor packet handling to skip only the RSSI byte for improved reliability
* Add SNR indicator localization and update UI references for nearby repeaters
* Handle loading state and error parsing in PathTraceMapScreen; update SNR indicator dialog content layout
* Throw an exception for unsupported LPP types in CayenneLpp class
* Refactor AppBarTitle widget to remove unused style parameter; update related screens to reflect changes
Improve SNR handling by adding validation for spreading factor range in snrUiFromSNR function
Update contact handling in MeshCoreConnector to fix variable naming and improve readability
Stop parsing unsupported LPP types in CayenneLpp to avoid misalignment
* Sort direct repeaters by last updated time and SNR; limit to top three for improved path management dialog
* Prevent notifications for chat and sensor adverts without a valid path
* Implement ranking system for direct repeaters based on SNR and recency; update related UI components to reflect changes
* Refactor localization keys for "neighbors" terminology across multiple languages
- Updated localization keys from "neighbours" to "neighbors" in the following files:
- app_localizations_bg.dart
- app_localizations_de.dart
- app_localizations_en.dart
- app_localizations_es.dart
- app_localizations_fr.dart
- app_localizations_it.dart
- app_localizations_nl.dart
- app_localizations_pl.dart
- app_localizations_pt.dart
- app_localizations_ru.dart
- app_localizations_sk.dart
- app_localizations_sl.dart
- app_localizations_sv.dart
- app_localizations_uk.dart
- app_localizations_zh.dart
- Updated corresponding ARB files to reflect the changes in keys.
- Renamed the NeighboursScreen to NeighborsScreen in the chat and repeater hub screens for consistency.
* Adjust ranking calculation for direct repeaters by adding offset to SNR for improved accuracy
* Fix typo in variable name for second direct repeater in path management dialog
* Refactor ranking calculation for direct repeaters and update path handling in channel message screens
* Refactor path handling in ChannelMessagePathScreen to improve logic for outgoing messages and channel messages
* Fix AppBarTitle horizontal overflow with long titles (#187)
* Initial plan
* Wrap title Column in Expanded to prevent horizontal overflow in AppBarTitle
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
* Refactor AppBarTitle widget to simplify Text widget initialization
* Add "Show All Paths" feature to chat path management
- Implemented localization for "Show All Paths" in multiple languages (DE, EN, ES, FR, IT, NL, PL, PT, RU, SK, SL, SV, UK, ZH).
- Updated path management dialog to include a toggle for showing all paths.
- Refactored path history display logic to conditionally show paths based on the toggle state.
- Cleaned up unused print statements and improved code readability in path tracing and chat screens.
* Refactor FeatureToggleRow visibility in chat and path management dialogs based on repeaters list
* Remove unused import of 'dart:ffi' in path_trace_map.dart
* Refactor repeater management logic and update UI state handling in chat and path management dialogs
* Refactor RX data handling and improve repeater management logic in chat and path management dialogs
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wel97459 <12990640+wel97459@users.noreply.github.com>
2026-02-20 20:27:38 -08:00
|
|
|
SelectableText(value, style: const TextStyle(fontSize: 14)),
|
2025-12-26 11:42:02 -07:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _formatLastSeen(DateTime lastSeen) {
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
final difference = now.difference(lastSeen);
|
|
|
|
|
|
|
|
|
|
if (difference.inSeconds < 60) {
|
2026-01-11 17:13:50 -07:00
|
|
|
return context.l10n.time_justNow;
|
2025-12-26 11:42:02 -07:00
|
|
|
} else if (difference.inMinutes < 60) {
|
2026-01-11 17:13:50 -07:00
|
|
|
return context.l10n.time_minutesAgo(difference.inMinutes);
|
2025-12-26 11:42:02 -07:00
|
|
|
} else if (difference.inHours < 24) {
|
2026-01-11 17:13:50 -07:00
|
|
|
return context.l10n.time_hoursAgo(difference.inHours);
|
2025-12-26 11:42:02 -07:00
|
|
|
} else {
|
2026-01-11 17:13:50 -07:00
|
|
|
return context.l10n.time_daysAgo(difference.inDays);
|
2025-12-26 11:42:02 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _showShareMarkerAtPositionSheet({
|
|
|
|
|
required BuildContext context,
|
|
|
|
|
required MeshCoreConnector connector,
|
|
|
|
|
required LatLng position,
|
|
|
|
|
}) {
|
|
|
|
|
showModalBottomSheet(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (sheetContext) => SafeArea(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
ListTile(
|
|
|
|
|
leading: const Icon(Icons.place),
|
2026-01-11 17:13:50 -07:00
|
|
|
title: Text(context.l10n.map_shareMarkerHere),
|
2025-12-26 11:42:02 -07:00
|
|
|
onTap: () {
|
|
|
|
|
Navigator.pop(sheetContext);
|
|
|
|
|
_shareMarker(
|
|
|
|
|
context: context,
|
|
|
|
|
connector: connector,
|
|
|
|
|
position: position,
|
2026-01-11 17:13:50 -07:00
|
|
|
defaultLabel: context.l10n.map_pointOfInterest,
|
2025-12-26 11:42:02 -07:00
|
|
|
flags: 'poi',
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
2026-03-14 09:33:37 -07:00
|
|
|
ListTile(
|
|
|
|
|
leading: const Icon(Icons.my_location),
|
|
|
|
|
title: Text(context.l10n.map_setAsMyLocation),
|
|
|
|
|
onTap: () async {
|
|
|
|
|
final messenger = ScaffoldMessenger.of(context);
|
2026-03-14 09:54:50 -07:00
|
|
|
final successMsg = context.l10n.settings_locationUpdated;
|
2026-03-14 09:33:37 -07:00
|
|
|
Navigator.pop(sheetContext);
|
2026-03-14 09:54:50 -07:00
|
|
|
if (!connector.isConnected) return;
|
2026-03-14 09:33:37 -07:00
|
|
|
await connector.setNodeLocation(
|
|
|
|
|
lat: position.latitude,
|
|
|
|
|
lon: position.longitude,
|
|
|
|
|
);
|
|
|
|
|
await connector.refreshDeviceInfo();
|
|
|
|
|
if (!mounted) return;
|
2026-03-14 09:54:50 -07:00
|
|
|
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
|
2026-03-14 09:33:37 -07:00
|
|
|
},
|
|
|
|
|
),
|
2025-12-26 11:42:02 -07:00
|
|
|
ListTile(
|
|
|
|
|
leading: const Icon(Icons.close),
|
2026-01-11 17:13:50 -07:00
|
|
|
title: Text(context.l10n.common_cancel),
|
2025-12-26 11:42:02 -07:00
|
|
|
onTap: () => Navigator.pop(sheetContext),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _shareMarker({
|
|
|
|
|
required BuildContext context,
|
|
|
|
|
required MeshCoreConnector connector,
|
|
|
|
|
required LatLng position,
|
|
|
|
|
required String defaultLabel,
|
|
|
|
|
required String flags,
|
|
|
|
|
}) async {
|
|
|
|
|
if (!connector.isConnected) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
2026-01-11 17:13:50 -07:00
|
|
|
SnackBar(content: Text(context.l10n.map_connectToShareMarkers)),
|
2025-12-26 11:42:02 -07:00
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final label = await _promptForLabel(context, defaultLabel);
|
2025-12-31 22:19:48 -07:00
|
|
|
if (label == null || !mounted) return;
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
final markerText = _formatMarkerMessage(position, label, flags);
|
2025-12-31 22:19:48 -07:00
|
|
|
if (!mounted) return;
|
|
|
|
|
|
2025-12-26 11:42:02 -07:00
|
|
|
await _showRecipientSheet(
|
2025-12-31 22:19:48 -07:00
|
|
|
// ignore: use_build_context_synchronously
|
2025-12-26 11:42:02 -07:00
|
|
|
context: context,
|
|
|
|
|
connector: connector,
|
|
|
|
|
markerText: markerText,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 19:11:13 -07:00
|
|
|
Future<String?> _promptForLabel(
|
|
|
|
|
BuildContext context,
|
|
|
|
|
String defaultLabel,
|
|
|
|
|
) async {
|
2025-12-26 11:42:02 -07:00
|
|
|
final controller = TextEditingController(text: defaultLabel);
|
|
|
|
|
return showDialog<String>(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (dialogContext) => AlertDialog(
|
2026-01-11 17:13:50 -07:00
|
|
|
title: Text(context.l10n.map_pinLabel),
|
2025-12-26 11:42:02 -07:00
|
|
|
content: TextField(
|
|
|
|
|
controller: controller,
|
2026-01-11 17:13:50 -07:00
|
|
|
decoration: InputDecoration(
|
|
|
|
|
hintText: context.l10n.map_label,
|
|
|
|
|
border: const OutlineInputBorder(),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(dialogContext),
|
2026-01-11 17:13:50 -07:00
|
|
|
child: Text(context.l10n.common_cancel),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
final label = controller.text.trim().replaceAll('|', '/');
|
2026-01-15 19:11:13 -07:00
|
|
|
Navigator.pop(
|
|
|
|
|
dialogContext,
|
|
|
|
|
label.isEmpty ? defaultLabel : label,
|
|
|
|
|
);
|
2025-12-26 11:42:02 -07:00
|
|
|
},
|
2026-01-11 17:13:50 -07:00
|
|
|
child: Text(context.l10n.common_continue),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _formatMarkerMessage(LatLng position, String label, String flags) {
|
|
|
|
|
final lat = position.latitude.toStringAsFixed(6);
|
|
|
|
|
final lon = position.longitude.toStringAsFixed(6);
|
|
|
|
|
return 'm:$lat,$lon|$label|$flags';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _showRecipientSheet({
|
|
|
|
|
required BuildContext context,
|
|
|
|
|
required MeshCoreConnector connector,
|
|
|
|
|
required String markerText,
|
|
|
|
|
}) async {
|
|
|
|
|
if (!connector.isLoadingChannels && connector.channels.isEmpty) {
|
|
|
|
|
connector.getChannels();
|
|
|
|
|
}
|
|
|
|
|
String query = '';
|
|
|
|
|
|
|
|
|
|
await showModalBottomSheet(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (sheetContext) => StatefulBuilder(
|
|
|
|
|
builder: (sheetContext, setSheetState) {
|
|
|
|
|
return Consumer<MeshCoreConnector>(
|
2026-01-11 17:13:50 -07:00
|
|
|
builder: (consumerContext, liveConnector, child) {
|
2025-12-26 11:42:02 -07:00
|
|
|
final allContacts = liveConnector.contacts
|
2026-01-15 19:11:13 -07:00
|
|
|
.where(
|
|
|
|
|
(contact) =>
|
|
|
|
|
contact.type != advTypeRepeater &&
|
|
|
|
|
contact.type != advTypeRoom,
|
|
|
|
|
)
|
2025-12-26 11:42:02 -07:00
|
|
|
.toList();
|
|
|
|
|
return SafeArea(
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
2026-01-11 17:13:50 -07:00
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
2026-01-15 19:11:13 -07:00
|
|
|
child: Text(
|
|
|
|
|
context.l10n.map_sendToContact,
|
|
|
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
|
|
|
),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
|
|
|
|
child: TextField(
|
|
|
|
|
decoration: InputDecoration(
|
Add localized search functionality for contacts (#244)
- Introduced new localization keys for searching contacts, users, favorites, repeaters, and room servers in multiple languages.
- Updated localization files for Italian, Bulgarian, German, English, Spanish, French, Dutch, Polish, Portuguese, Russian, Slovak, Slovenian, Swedish, Ukrainian, and Chinese.
- Enhanced the contacts screen to dynamically display search hints based on the selected contact type filter.
- Modified the map screen to utilize the new search functionality for contacts without a number.
2026-02-26 22:53:52 -08:00
|
|
|
hintText:
|
|
|
|
|
context.l10n.contacts_searchContactsNoNumber,
|
2025-12-26 11:42:02 -07:00
|
|
|
prefixIcon: const Icon(Icons.search),
|
|
|
|
|
border: OutlineInputBorder(
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
),
|
2026-01-15 19:11:13 -07:00
|
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: 16,
|
|
|
|
|
vertical: 12,
|
|
|
|
|
),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
setSheetState(() {
|
|
|
|
|
query = value.toLowerCase();
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
...allContacts
|
2026-01-15 19:11:13 -07:00
|
|
|
.where(
|
|
|
|
|
(contact) =>
|
|
|
|
|
query.isEmpty ||
|
|
|
|
|
matchesContactQuery(contact, query),
|
|
|
|
|
)
|
2025-12-26 11:42:02 -07:00
|
|
|
.map((contact) {
|
2026-01-15 19:11:13 -07:00
|
|
|
return ListTile(
|
|
|
|
|
leading: const Icon(Icons.person),
|
|
|
|
|
title: Text(contact.name),
|
|
|
|
|
onTap: () {
|
|
|
|
|
Navigator.pop(sheetContext);
|
|
|
|
|
liveConnector.sendMessage(contact, markerText);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}),
|
2026-01-11 17:13:50 -07:00
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
2026-01-15 19:11:13 -07:00
|
|
|
child: Text(
|
|
|
|
|
context.l10n.map_sendToChannel,
|
|
|
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
|
|
|
),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
if (liveConnector.isLoadingChannels)
|
|
|
|
|
const Padding(
|
2026-01-15 19:11:13 -07:00
|
|
|
padding: EdgeInsets.symmetric(
|
|
|
|
|
horizontal: 16,
|
|
|
|
|
vertical: 8,
|
|
|
|
|
),
|
2025-12-26 11:42:02 -07:00
|
|
|
child: LinearProgressIndicator(),
|
|
|
|
|
)
|
2026-01-15 19:11:13 -07:00
|
|
|
else if (liveConnector.channels
|
|
|
|
|
.where((c) => !c.isEmpty)
|
|
|
|
|
.isEmpty)
|
2026-01-11 17:13:50 -07:00
|
|
|
Padding(
|
2026-01-15 19:11:13 -07:00
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: 16,
|
|
|
|
|
vertical: 8,
|
|
|
|
|
),
|
2026-01-11 17:13:50 -07:00
|
|
|
child: Text(context.l10n.map_noChannelsAvailable),
|
2025-12-26 11:42:02 -07:00
|
|
|
)
|
|
|
|
|
else
|
2026-01-15 19:11:13 -07:00
|
|
|
...liveConnector.channels.where((c) => !c.isEmpty).map((
|
|
|
|
|
channel,
|
|
|
|
|
) {
|
2025-12-26 11:42:02 -07:00
|
|
|
final isPublic = _isPublicChannel(channel);
|
2026-01-15 19:11:13 -07:00
|
|
|
final label = channel.name.isEmpty
|
|
|
|
|
? 'Channel ${channel.index}'
|
|
|
|
|
: channel.name;
|
2025-12-26 11:42:02 -07:00
|
|
|
return ListTile(
|
|
|
|
|
leading: Icon(
|
|
|
|
|
isPublic ? Icons.public : Icons.tag,
|
|
|
|
|
color: isPublic ? Colors.orange : Colors.blue,
|
|
|
|
|
),
|
|
|
|
|
title: Text(label),
|
2026-01-15 19:11:13 -07:00
|
|
|
subtitle: isPublic
|
|
|
|
|
? Text(context.l10n.channels_publicChannel)
|
|
|
|
|
: null,
|
2025-12-26 11:42:02 -07:00
|
|
|
onTap: () async {
|
|
|
|
|
Navigator.pop(sheetContext);
|
|
|
|
|
final canSend = isPublic
|
|
|
|
|
? await _confirmPublicShare(context, label)
|
|
|
|
|
: true;
|
|
|
|
|
if (canSend) {
|
2026-01-15 19:11:13 -07:00
|
|
|
liveConnector.sendChannelMessage(
|
|
|
|
|
channel,
|
|
|
|
|
markerText,
|
|
|
|
|
);
|
2025-12-26 11:42:02 -07:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool _isPublicChannel(Channel channel) {
|
2025-12-26 13:33:03 -07:00
|
|
|
return channel.isPublicChannel;
|
2025-12-26 11:42:02 -07:00
|
|
|
}
|
|
|
|
|
|
2026-01-15 19:11:13 -07:00
|
|
|
Future<bool> _confirmPublicShare(
|
|
|
|
|
BuildContext context,
|
|
|
|
|
String channelLabel,
|
|
|
|
|
) async {
|
2025-12-26 11:42:02 -07:00
|
|
|
final result = await showDialog<bool>(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (dialogContext) => AlertDialog(
|
2026-01-11 17:13:50 -07:00
|
|
|
title: Text(context.l10n.map_publicLocationShare),
|
2026-01-15 19:11:13 -07:00
|
|
|
content: Text(
|
|
|
|
|
context.l10n.map_publicLocationShareConfirm(channelLabel),
|
|
|
|
|
),
|
2025-12-26 11:42:02 -07:00
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
2026-01-11 17:13:50 -07:00
|
|
|
child: Text(context.l10n.common_cancel),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
2026-01-11 17:13:50 -07:00
|
|
|
child: Text(context.l10n.common_share),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
return result ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 19:11:13 -07:00
|
|
|
void _showFilterDialog(
|
|
|
|
|
BuildContext context,
|
|
|
|
|
AppSettingsService settingsService,
|
|
|
|
|
) {
|
2025-12-26 11:42:02 -07:00
|
|
|
showDialog(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (dialogContext) => AlertDialog(
|
2026-01-11 17:13:50 -07:00
|
|
|
title: Text(context.l10n.map_filterNodes),
|
2025-12-26 11:42:02 -07:00
|
|
|
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
|
|
|
|
content: SingleChildScrollView(
|
|
|
|
|
child: Consumer<AppSettingsService>(
|
2026-01-11 17:13:50 -07:00
|
|
|
builder: (consumerContext, service, child) {
|
2025-12-26 11:42:02 -07:00
|
|
|
final settings = service.settings;
|
|
|
|
|
return Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
2026-01-11 17:13:50 -07:00
|
|
|
Text(
|
|
|
|
|
context.l10n.map_nodeTypes,
|
|
|
|
|
style: const TextStyle(
|
2025-12-26 11:42:02 -07:00
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
CheckboxListTile(
|
2026-01-11 17:13:50 -07:00
|
|
|
title: Text(context.l10n.map_chatNodes),
|
2025-12-26 11:42:02 -07:00
|
|
|
value: settings.mapShowChatNodes,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
service.setMapShowChatNodes(value ?? true);
|
|
|
|
|
},
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
),
|
|
|
|
|
CheckboxListTile(
|
2026-01-11 17:13:50 -07:00
|
|
|
title: Text(context.l10n.map_repeaters),
|
2025-12-26 11:42:02 -07:00
|
|
|
value: settings.mapShowRepeaters,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
service.setMapShowRepeaters(value ?? true);
|
|
|
|
|
},
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
),
|
|
|
|
|
CheckboxListTile(
|
2026-01-11 17:13:50 -07:00
|
|
|
title: Text(context.l10n.map_otherNodes),
|
2025-12-26 11:42:02 -07:00
|
|
|
value: settings.mapShowOtherNodes,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
service.setMapShowOtherNodes(value ?? true);
|
|
|
|
|
},
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
),
|
2026-03-06 15:02:37 -07:00
|
|
|
CheckboxListTile(
|
|
|
|
|
title: Text(context.l10n.map_showGuessedLocations),
|
|
|
|
|
value: settings.mapShowGuessedLocations,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
service.setMapShowGuessedLocations(value ?? true);
|
|
|
|
|
},
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
),
|
2026-03-12 23:08:46 -07:00
|
|
|
CheckboxListTile(
|
|
|
|
|
title: Text(context.l10n.map_showDiscoveryContacts),
|
|
|
|
|
value: settings.mapShowDiscoveryContacts,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
service.setMapShowDiscoveryContacts(value ?? true);
|
|
|
|
|
},
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
),
|
2026-02-22 16:00:51 -08:00
|
|
|
CheckboxListTile(
|
|
|
|
|
title: Text(context.l10n.map_showOverlaps),
|
|
|
|
|
value: settings.mapShowOverlaps,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
service.setMapShowOverlaps(value ?? true);
|
|
|
|
|
},
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
),
|
|
|
|
|
|
2025-12-26 11:42:02 -07:00
|
|
|
const SizedBox(height: 16),
|
2026-01-11 17:13:50 -07:00
|
|
|
Text(
|
|
|
|
|
context.l10n.map_keyPrefix,
|
|
|
|
|
style: const TextStyle(
|
2025-12-26 11:42:02 -07:00
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
CheckboxListTile(
|
2026-01-11 17:13:50 -07:00
|
|
|
title: Text(context.l10n.map_filterByKeyPrefix),
|
2025-12-26 11:42:02 -07:00
|
|
|
value: settings.mapKeyPrefixEnabled,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
service.setMapKeyPrefixEnabled(value ?? false);
|
|
|
|
|
},
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
),
|
|
|
|
|
TextFormField(
|
|
|
|
|
initialValue: settings.mapKeyPrefix,
|
|
|
|
|
enabled: settings.mapKeyPrefixEnabled,
|
2026-01-11 17:13:50 -07:00
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: context.l10n.map_publicKeyPrefix,
|
2025-12-26 11:42:02 -07:00
|
|
|
hintText: 'e.g. ab12',
|
2026-01-11 17:13:50 -07:00
|
|
|
border: const OutlineInputBorder(),
|
2025-12-26 11:42:02 -07:00
|
|
|
isDense: true,
|
|
|
|
|
),
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
service.setMapKeyPrefix(value);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
2026-01-11 17:13:50 -07:00
|
|
|
Text(
|
|
|
|
|
context.l10n.map_markers,
|
|
|
|
|
style: const TextStyle(
|
2025-12-26 11:42:02 -07:00
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
CheckboxListTile(
|
2026-01-11 17:13:50 -07:00
|
|
|
title: Text(context.l10n.map_showSharedMarkers),
|
2025-12-26 11:42:02 -07:00
|
|
|
value: settings.mapShowMarkers,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
service.setMapShowMarkers(value ?? true);
|
|
|
|
|
},
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
2026-01-11 17:13:50 -07:00
|
|
|
Text(
|
|
|
|
|
context.l10n.map_lastSeenTime,
|
|
|
|
|
style: const TextStyle(
|
2025-12-26 11:42:02 -07:00
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(
|
|
|
|
|
_getTimeFilterLabel(settings.mapTimeFilterHours),
|
2026-01-15 19:11:13 -07:00
|
|
|
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
Slider(
|
|
|
|
|
value: _hoursToSliderValue(settings.mapTimeFilterHours),
|
|
|
|
|
min: 0,
|
|
|
|
|
max: 100,
|
|
|
|
|
divisions: 100,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
final hours = _sliderValueToHours(value);
|
|
|
|
|
service.setMapTimeFilterHours(hours);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(dialogContext),
|
2026-01-11 17:13:50 -07:00
|
|
|
child: Text(context.l10n.common_close),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert hours to slider value (0-100) with exponential scaling
|
|
|
|
|
double _hoursToSliderValue(double hours) {
|
|
|
|
|
if (hours == 0) return 100; // All time
|
|
|
|
|
|
|
|
|
|
// Map hours exponentially
|
|
|
|
|
// 0-24h: 0-40
|
|
|
|
|
// 24h-7d: 40-60
|
|
|
|
|
// 7d-30d: 60-80
|
|
|
|
|
// 30d-6mo: 80-99
|
|
|
|
|
// All time: 100
|
|
|
|
|
|
|
|
|
|
if (hours <= 24) {
|
|
|
|
|
return (hours / 24) * 40;
|
2026-01-15 19:11:13 -07:00
|
|
|
} else if (hours <= 168) {
|
|
|
|
|
// 7 days
|
2025-12-26 11:42:02 -07:00
|
|
|
return 40 + ((hours - 24) / (168 - 24)) * 20;
|
2026-01-15 19:11:13 -07:00
|
|
|
} else if (hours <= 720) {
|
|
|
|
|
// 30 days
|
2025-12-26 11:42:02 -07:00
|
|
|
return 60 + ((hours - 168) / (720 - 168)) * 20;
|
2026-01-15 19:11:13 -07:00
|
|
|
} else if (hours <= 4380) {
|
|
|
|
|
// 6 months
|
2025-12-26 11:42:02 -07:00
|
|
|
return 80 + ((hours - 720) / (4380 - 720)) * 19;
|
|
|
|
|
} else {
|
|
|
|
|
return 100;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert slider value (0-100) to hours with exponential scaling
|
|
|
|
|
double _sliderValueToHours(double value) {
|
|
|
|
|
if (value >= 99.5) return 0; // All time
|
|
|
|
|
|
|
|
|
|
if (value <= 40) {
|
|
|
|
|
return (value / 40) * 24; // 0-24 hours
|
|
|
|
|
} else if (value <= 60) {
|
|
|
|
|
return 24 + ((value - 40) / 20) * (168 - 24); // 1-7 days
|
|
|
|
|
} else if (value <= 80) {
|
|
|
|
|
return 168 + ((value - 60) / 20) * (720 - 168); // 7-30 days
|
|
|
|
|
} else {
|
|
|
|
|
return 720 + ((value - 80) / 19) * (4380 - 720); // 30 days - 6 months
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _getTimeFilterLabel(double hours) {
|
2026-01-11 17:13:50 -07:00
|
|
|
if (hours == 0) return context.l10n.time_allTime;
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
if (hours < 1) {
|
2026-01-11 17:13:50 -07:00
|
|
|
return '${(hours * 60).round()} ${context.l10n.time_minutes}';
|
2025-12-26 11:42:02 -07:00
|
|
|
} else if (hours < 24) {
|
2026-01-11 17:13:50 -07:00
|
|
|
final h = hours.round();
|
|
|
|
|
return '$h ${h == 1 ? context.l10n.time_hour : context.l10n.time_hours}';
|
2025-12-26 11:42:02 -07:00
|
|
|
} else if (hours < 168) {
|
|
|
|
|
final days = (hours / 24).round();
|
2026-01-11 17:13:50 -07:00
|
|
|
return '$days ${days == 1 ? context.l10n.time_day : context.l10n.time_days}';
|
2025-12-26 11:42:02 -07:00
|
|
|
} else if (hours < 720) {
|
|
|
|
|
final weeks = (hours / 168).round();
|
2026-01-11 17:13:50 -07:00
|
|
|
return '$weeks ${weeks == 1 ? context.l10n.time_week : context.l10n.time_weeks}';
|
2025-12-26 11:42:02 -07:00
|
|
|
} else if (hours < 4380) {
|
|
|
|
|
final months = (hours / 730).round();
|
2026-01-11 17:13:50 -07:00
|
|
|
return '$months ${months == 1 ? context.l10n.time_month : context.l10n.time_months}';
|
2025-12-26 11:42:02 -07:00
|
|
|
} else {
|
2026-01-11 17:13:50 -07:00
|
|
|
return context.l10n.time_allTime;
|
2025-12-26 11:42:02 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 00:10:34 -08:00
|
|
|
|
2026-03-24 17:45:54 -07:00
|
|
|
void _addToPath(BuildContext context, Contact contact, {LatLng? position}) {
|
2026-02-14 00:10:34 -08:00
|
|
|
setState(() {
|
|
|
|
|
_pathTrace.add(
|
|
|
|
|
contact.publicKey[0],
|
|
|
|
|
); // Add first 16 bytes of public key to path trace
|
2026-03-24 17:45:54 -07:00
|
|
|
_pathTraceContacts.add(
|
|
|
|
|
contact.copyWith(
|
|
|
|
|
latitude: position?.latitude ?? contact.latitude,
|
|
|
|
|
longitude: position?.longitude ?? contact.longitude,
|
|
|
|
|
),
|
|
|
|
|
); // Add contact to path trace contacts
|
|
|
|
|
_points.add(position ?? LatLng(contact.latitude!, contact.longitude!));
|
2026-02-14 00:10:34 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 16:00:51 -08:00
|
|
|
void _startPath(LatLng position) {
|
2026-02-14 00:10:34 -08:00
|
|
|
setState(() {
|
|
|
|
|
_isBuildingPathTrace = true;
|
|
|
|
|
_pathTrace.clear();
|
2026-03-24 17:45:54 -07:00
|
|
|
_pathTraceContacts.clear();
|
2026-02-14 00:10:34 -08:00
|
|
|
_points.clear();
|
|
|
|
|
_polylines.clear();
|
2026-02-22 16:00:51 -08:00
|
|
|
_points.add(position);
|
2026-02-14 00:10:34 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _removePath() {
|
|
|
|
|
setState(() {
|
2026-03-26 20:37:46 -07:00
|
|
|
_pathTraceContacts.removeLast();
|
2026-03-25 18:30:27 -07:00
|
|
|
_pathTrace.removeLast(); // Remove last node from path trace
|
2026-02-14 00:10:34 -08:00
|
|
|
_points.removeLast(); // Remove last point from points list
|
|
|
|
|
_polylines.clear(); // Clear polylines
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildPathTraceOverlay() {
|
|
|
|
|
final l10n = context.l10n;
|
2026-02-21 01:08:23 -05:00
|
|
|
final isImperial =
|
|
|
|
|
context.read<AppSettingsService>().settings.unitSystem ==
|
|
|
|
|
UnitSystem.imperial;
|
2026-02-14 00:10:34 -08:00
|
|
|
return Positioned(
|
|
|
|
|
top: 16,
|
|
|
|
|
left: 16,
|
|
|
|
|
right: 16,
|
|
|
|
|
child: Card(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(12.0),
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
l10n.contacts_pathTrace,
|
|
|
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
|
|
|
),
|
|
|
|
|
if (_pathTrace.isEmpty) const SizedBox(height: 8),
|
|
|
|
|
if (_pathTrace.isEmpty)
|
|
|
|
|
Text(l10n.map_tapToAdd, style: TextStyle(fontSize: 12)),
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
if (_pathTrace.isNotEmpty)
|
|
|
|
|
Text(
|
2026-02-21 01:08:23 -05:00
|
|
|
"${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points), isImperial: isImperial)}",
|
2026-02-14 00:10:34 -08:00
|
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
|
|
|
|
),
|
|
|
|
|
SelectableText(
|
|
|
|
|
_pathTrace
|
|
|
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
|
|
|
.join(','),
|
|
|
|
|
style: TextStyle(fontSize: 18),
|
|
|
|
|
),
|
2026-02-22 16:00:51 -08:00
|
|
|
// const SizedBox(height: 6),
|
2026-02-21 01:08:23 -05:00
|
|
|
Wrap(
|
|
|
|
|
alignment: WrapAlignment.center,
|
2026-02-22 16:00:51 -08:00
|
|
|
spacing: 1,
|
|
|
|
|
runSpacing: 1,
|
2026-02-14 00:10:34 -08:00
|
|
|
children: [
|
|
|
|
|
if (_pathTrace.isNotEmpty)
|
2026-02-22 16:00:51 -08:00
|
|
|
IconButton(
|
|
|
|
|
onPressed: () {
|
2026-03-23 19:26:05 -07:00
|
|
|
final hashW = context
|
|
|
|
|
.read<MeshCoreConnector>()
|
|
|
|
|
.pathHashByteWidth;
|
2026-02-22 16:00:51 -08:00
|
|
|
Navigator.push(
|
|
|
|
|
context,
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (context) => PathTraceMapScreen(
|
|
|
|
|
title: l10n.contacts_pathTrace,
|
|
|
|
|
path: Uint8List.fromList(_pathTrace),
|
2026-03-23 19:24:27 -07:00
|
|
|
pathHashByteWidth: hashW,
|
2026-03-24 17:45:54 -07:00
|
|
|
pathContacts: _pathTraceContacts,
|
2026-02-22 16:00:51 -08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
setState(() {
|
|
|
|
|
_isBuildingPathTrace = false;
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-02-24 19:01:22 -08:00
|
|
|
tooltip: l10n.map_runTrace,
|
2026-02-22 16:00:51 -08:00
|
|
|
icon: const Icon(Icons.arrow_forward_outlined),
|
|
|
|
|
),
|
|
|
|
|
if (_pathTrace.isNotEmpty)
|
|
|
|
|
IconButton(
|
2026-02-14 00:10:34 -08:00
|
|
|
onPressed: () {
|
|
|
|
|
Navigator.push(
|
|
|
|
|
context,
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
builder: (context) => PathTraceMapScreen(
|
|
|
|
|
title: l10n.contacts_pathTrace,
|
|
|
|
|
path: Uint8List.fromList(_pathTrace),
|
2026-02-22 16:00:51 -08:00
|
|
|
flipPathAround: true,
|
2026-02-14 00:10:34 -08:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
setState(() {
|
|
|
|
|
_isBuildingPathTrace = false;
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-02-24 19:01:22 -08:00
|
|
|
tooltip: l10n.map_runTraceWithReturnPath,
|
2026-02-22 16:00:51 -08:00
|
|
|
icon: const Icon(Icons.replay),
|
2026-02-14 00:10:34 -08:00
|
|
|
),
|
|
|
|
|
if (_pathTrace.isNotEmpty)
|
2026-02-22 16:00:51 -08:00
|
|
|
IconButton(
|
2026-02-14 00:10:34 -08:00
|
|
|
onPressed: _removePath,
|
2026-02-24 19:01:22 -08:00
|
|
|
tooltip: l10n.map_removeLast,
|
|
|
|
|
icon: const Icon(Icons.undo),
|
2026-02-14 00:10:34 -08:00
|
|
|
),
|
|
|
|
|
if (_pathTrace.isEmpty)
|
2026-02-22 16:00:51 -08:00
|
|
|
IconButton(
|
2026-02-14 00:10:34 -08:00
|
|
|
onPressed: () {
|
|
|
|
|
setState(() {
|
|
|
|
|
_isBuildingPathTrace = false;
|
|
|
|
|
_pathTrace.clear();
|
|
|
|
|
_points.clear();
|
|
|
|
|
_polylines.clear();
|
|
|
|
|
});
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(content: Text(l10n.map_pathTraceCancelled)),
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-02-24 19:01:22 -08:00
|
|
|
tooltip: l10n.common_cancel,
|
2026-02-22 16:00:51 -08:00
|
|
|
icon: const Icon(Icons.close),
|
2026-02-14 00:10:34 -08:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-12-26 11:42:02 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 15:02:37 -07:00
|
|
|
class _GuessedLocation {
|
|
|
|
|
final Contact contact;
|
|
|
|
|
final LatLng position;
|
|
|
|
|
final bool highConfidence;
|
|
|
|
|
|
|
|
|
|
_GuessedLocation({
|
|
|
|
|
required this.contact,
|
|
|
|
|
required this.position,
|
|
|
|
|
required this.highConfidence,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 11:42:02 -07:00
|
|
|
class _MarkerPayload {
|
|
|
|
|
final LatLng position;
|
|
|
|
|
final String label;
|
|
|
|
|
final String flags;
|
|
|
|
|
|
|
|
|
|
_MarkerPayload({
|
|
|
|
|
required this.position,
|
|
|
|
|
required this.label,
|
|
|
|
|
required this.flags,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _SharedMarker {
|
|
|
|
|
final String id;
|
|
|
|
|
final LatLng position;
|
|
|
|
|
final String label;
|
|
|
|
|
final String flags;
|
|
|
|
|
final String fromName;
|
|
|
|
|
final String sourceLabel;
|
|
|
|
|
final bool isChannel;
|
|
|
|
|
final bool isPublicChannel;
|
|
|
|
|
|
|
|
|
|
_SharedMarker({
|
|
|
|
|
required this.id,
|
|
|
|
|
required this.position,
|
|
|
|
|
required this.label,
|
|
|
|
|
required this.flags,
|
|
|
|
|
required this.fromName,
|
|
|
|
|
required this.sourceLabel,
|
|
|
|
|
required this.isChannel,
|
|
|
|
|
required this.isPublicChannel,
|
|
|
|
|
});
|
|
|
|
|
}
|