From ca538565dae6d636faaaca7e4f43edd3472d5af8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 22 Mar 2026 14:14:17 -0400 Subject: [PATCH 01/14] - Aligned dot marker colors with web map coverage squares. Discovery markers changed from purple to cyan, RX markers changed from blue to purple. TX (green) and Trace (cyan) were already aligned. Applies to map markers, noise floor chart, status bar chips, log screen filters, and home screen stats. --- lib/models/noise_floor_session.dart | 15 ++++++------ lib/screens/connection_screen.dart | 2 +- lib/screens/home_screen.dart | 17 +++++++------- lib/screens/log_screen.dart | 19 +++++++-------- lib/services/meshcore/connection.dart | 28 ++++++++++++++++++++++- lib/utils/ping_colors.dart | 29 +++++++++++++++++++++++ lib/widgets/map_widget.dart | 33 ++++++++++++++------------- lib/widgets/noise_floor_chart.dart | 13 ++++++----- lib/widgets/status_bar.dart | 17 +++++++------- 9 files changed, 117 insertions(+), 56 deletions(-) create mode 100644 lib/utils/ping_colors.dart diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index 9c6b574..6bd9fbf 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; +import '../utils/ping_colors.dart'; part 'noise_floor_session.g.dart'; @@ -100,13 +101,13 @@ class PingEventMarker extends HiveObject { /// Get the color for this event type Color get color => switch (type) { - PingEventType.txSuccess => Colors.green, - PingEventType.txFail => Colors.red, - PingEventType.rx => Colors.blue, - PingEventType.discSuccess => Colors.purple, - PingEventType.discFail => Colors.grey, - PingEventType.traceSuccess => Colors.cyan, - PingEventType.traceFail => Colors.grey, + PingEventType.txSuccess => PingColors.txSuccess, + PingEventType.txFail => PingColors.txFail, + PingEventType.rx => PingColors.rx, + PingEventType.discSuccess => PingColors.discSuccess, + PingEventType.discFail => PingColors.discFail, + PingEventType.traceSuccess => PingColors.traceSuccess, + PingEventType.traceFail => PingColors.noResponse, }; /// Get a display label for this event type diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index ce97f87..993466e 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -439,7 +439,7 @@ class _ConnectionScreenState extends State with WidgetsBinding } // Portrait: compact vertical layout (bottom bar provided by _buildBody) - return Padding( + return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d984a98..2e0150f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; import '../widgets/connection_panel.dart'; import '../widgets/map_widget.dart'; import '../widgets/ping_controls.dart'; @@ -155,7 +156,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.arrow_upward, appState.pingStats.txCount, - Colors.green, + PingColors.txSuccess, onTap: withTapHandlers ? () => _showInfoPopup('tx', appState) : null, ), const SizedBox(width: 8), @@ -163,7 +164,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.arrow_downward, appState.pingStats.rxCount, - Colors.blue, + PingColors.rx, onTap: withTapHandlers ? () => _showInfoPopup('rx', appState) : null, ), const SizedBox(width: 8), @@ -171,7 +172,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.radar, appState.pingStats.discCount, - const Color(0xFF7B68EE), + PingColors.discSuccess, onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), @@ -179,7 +180,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.route, appState.pingStats.traceCount, - Colors.cyan, + PingColors.traceSuccess, onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, ), const SizedBox(width: 8), @@ -331,16 +332,16 @@ class _HomeScreenState extends State { return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, Colors.green); + return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, Colors.blue); + return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); case 'disc': - return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, const Color(0xFF7B68EE)); + return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, PingColors.discSuccess); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 97267e0..5c09b5e 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../models/log_entry.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; +import '../utils/ping_colors.dart'; import '../widgets/repeater_id_chip.dart'; /// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors @@ -434,13 +435,13 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: IntrinsicHeight( child: Row( children: [ - _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, Colors.green, isFirst: true), + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, PingColors.txSuccess, isFirst: true), _segmentDivider(context), - _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, Colors.blue), + _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), _segmentDivider(context), - _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, const Color(0xFF7B68EE)), + _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, PingColors.discSuccess), _segmentDivider(context), - _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, Colors.cyan, isLast: true), + _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, PingColors.traceSuccess, isLast: true), ], ), ), @@ -548,10 +549,10 @@ class _AllPingsTabState extends State<_AllPingsTab> { static Widget _buildTypeBadge(PingLogType type) { final (label, color) = switch (type) { - PingLogType.tx => ('TX', Colors.green), - PingLogType.rx => ('RX', Colors.blue), - PingLogType.disc => ('DISC', const Color(0xFF7B68EE)), - PingLogType.trace => ('TRC', Colors.cyan), + PingLogType.tx => ('TX', PingColors.txSuccess), + PingLogType.rx => ('RX', PingColors.rx), + PingLogType.disc => ('DISC', PingColors.discSuccess), + PingLogType.trace => ('TRC', PingColors.traceSuccess), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), @@ -833,7 +834,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w500, - color: Color(0xFF7B68EE), + color: PingColors.discSuccess, ), ), ], diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 07c0810..fd0f389 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -84,6 +84,7 @@ class MeshCoreConnection { Completer? _deviceQueryCompleter; Completer? _selfInfoCompleter; Completer? _sentCompleter; + Completer? _setTimeCompleter; Completer? _channelInfoCompleter; Completer? _statsCompleter; Completer? _exportContactCompleter; @@ -369,10 +370,23 @@ class MeshCoreConnection { switch (responseCode) { case ResponseCodes.ok: debugLog('[CONN] Received OK response'); + _setTimeCompleter?.complete(); + _setTimeCompleter = null; break; case ResponseCodes.err: final errorCode = reader.remainingBytesCount > 0 ? reader.readByte() : 0; debugLog('[CONN] Received ERR response (error code: $errorCode)'); + // Time sync: error code 6 (ERR_CODE_ILLEGAL_ARG) means "no sync needed" — treat as success + if (_setTimeCompleter != null) { + if (errorCode == 6) { + debugLog('[CONN] Time sync not needed (error code 6) - treating as success'); + } else { + debugWarn('[CONN] Time sync error (code $errorCode) - continuing anyway'); + } + _setTimeCompleter?.complete(); + _setTimeCompleter = null; + break; + } // Complete any pending completers with error final errException = Exception('Command error (code $errorCode)'); _statsCompleter?.completeError(errException); @@ -758,12 +772,23 @@ class MeshCoreConnection { ); } - /// Set device time + /// Set device time and await OK/ERROR response from device Future setDeviceTime(int epochSecs) async { + _setTimeCompleter = Completer(); + final future = _setTimeCompleter!.future; + final data = BufferWriter(); data.writeByte(CommandCodes.setDeviceTime); data.writeUInt32LE(epochSecs); await _sendToRadio(data); + + return future.timeout( + const Duration(seconds: 5), + onTimeout: () { + _setTimeCompleter = null; + debugWarn('[CONN] Time sync timed out - continuing anyway'); + }, + ); } /// Set TX power @@ -1158,6 +1183,7 @@ class MeshCoreConnection { void dispose() { _stopNoiseFloorPolling(); _stopBatteryPolling(); + _setTimeCompleter = null; _dataSubscription?.cancel(); _stepController.close(); _channelMessageController.close(); diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart new file mode 100644 index 0000000..2587a93 --- /dev/null +++ b/lib/utils/ping_colors.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +/// Centralized color constants for ping types (TX, RX, DISC, Trace). +/// +/// Dot/marker colors are aligned with coverage layer squares on the +/// MeshMapper web map: +/// BIDIR=#7EE094, TX=#FD8928, DISC/TRACE=#51D4E9, RX=#7D54C7, +/// DEAD=#9E9689, DROP=#E04F5D +class PingColors { + PingColors._(); + + // ── TX (green — we can't distinguish BIDIR vs TX client-side) ── + static const Color txSuccess = Color(0xFF4CAF50); + static const Color txSuccessLegend = Color(0xFF22C55E); + static const Color txFail = Color(0xFFF44336); + + // ── RX (purple — matches RX web map squares #7D54C7) ── + static const Color rx = Color(0xFF7D54C7); + + // ── DISC (cyan — matches DISC/TRACE web map squares #51D4E9) ── + static const Color discSuccess = Color(0xFF51D4E9); + static const Color discFail = Color(0xFF9E9E9E); + + // ── Trace (cyan family — same web map layer as DISC) ── + static const Color traceSuccess = Color(0xFF00BCD4); + + // ── Shared ── + static const Color noResponse = Color(0xFF9E9E9E); +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7d2e2ef..9d4a3db 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -14,6 +14,7 @@ import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; /// Map style options @@ -732,10 +733,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Color for the overlay ping-type dot static Color _overlayTypeColor(OverlayPingType type) { return switch (type) { - OverlayPingType.tx => Colors.green, - OverlayPingType.disc => Colors.purple, - OverlayPingType.trace => Colors.cyan, - OverlayPingType.rx => Colors.blue, + OverlayPingType.tx => PingColors.txSuccess, + OverlayPingType.disc => PingColors.discSuccess, + OverlayPingType.trace => PingColors.traceSuccess, + OverlayPingType.rx => PingColors.rx, }; } @@ -1229,49 +1230,49 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ _buildLegendItem( context: context, - color: const Color(0xFF22C55E), + color: PingColors.txSuccessLegend, label: 'TX', description: 'Location where you sent a ping and heard a repeater', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: Colors.red, + color: PingColors.txFail, label: 'TX', description: 'Location where you sent a ping but no repeater was heard', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: const Color(0xFF0EA5E9), + color: PingColors.rx, label: 'RX', description: 'Location where you received a message from the mesh', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: const Color(0xFF7D54C7), + color: PingColors.discSuccess, label: 'DISC', description: 'Location where you sent a discovery request and a repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: Colors.cyan, + color: PingColors.traceSuccess, label: 'TRC', description: 'Location where a trace reached the repeater', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, - color: Colors.grey, + color: PingColors.discFail, label: 'DISC', description: 'Location where you sent a discovery request but no repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, - color: Colors.grey, + color: PingColors.noResponse, label: 'TRC', description: 'Location where a trace got no response', ), @@ -1670,7 +1671,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { onTap: () => _showTxPingDetails(ping), child: Container( decoration: BoxDecoration( - color: ping.heardRepeaters.isEmpty ? Colors.red : Colors.green, + color: ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: const [ @@ -1690,8 +1691,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { List _buildRxMarkers(List pings) { return pings.map((ping) { - // Use blue to match the RX chip in status bar - const color = Colors.blue; + // Use purple to match RX coverage squares on web map + const color = PingColors.rx; return Marker( point: LatLng(ping.latitude, ping.longitude), @@ -2026,8 +2027,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// DISC marker color (#7B68EE - medium slate blue/purple) - static const Color _discMarkerColor = Color(0xFF7B68EE); + /// DISC marker color (#51D4E9 - cyan, matches DISC/TRACE web map squares) + static const Color _discMarkerColor = PingColors.discSuccess; /// Repeater marker color (#a52163 - magenta/pink) - Active static const Color _repeaterMarkerColor = Color(0xFFA52163); diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index aa760b4..8659252 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/noise_floor_session.dart'; import '../providers/app_state_provider.dart'; +import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; /// Interactive noise floor chart with pinch-to-zoom and pan @@ -848,12 +849,12 @@ class InteractiveNoiseFloorChartState extends State runSpacing: 8, alignment: WrapAlignment.center, children: [ - _legendItem(context, Colors.green, 'TX Success'), - _legendItem(context, Colors.red, 'TX Fail'), - _legendItem(context, Colors.blue, 'RX'), - _legendItem(context, Colors.purple, 'DISC Success'), - _legendItem(context, Colors.cyan, 'Trace Success'), - _legendItem(context, Colors.grey, 'No Response'), + _legendItem(context, PingColors.txSuccess, 'TX Success'), + _legendItem(context, PingColors.txFail, 'TX Fail'), + _legendItem(context, PingColors.rx, 'RX'), + _legendItem(context, PingColors.discSuccess, 'DISC Success'), + _legendItem(context, PingColors.traceSuccess, 'Trace Success'), + _legendItem(context, PingColors.noResponse, 'No Response'), ], ); } diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 563d28f..403b6d9 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// Status bar showing GPS, connection, and queue status class StatusBar extends StatefulWidget { @@ -150,16 +151,16 @@ class _StatusBarState extends State { return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, Colors.green); + return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, Colors.blue); + return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); case 'disc': - return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, const Color(0xFF7B68EE)); + return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, PingColors.discSuccess); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -239,7 +240,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.arrow_upward, value: appState.pingStats.txCount, - color: Colors.green, + color: PingColors.txSuccess, onTap: () => _showInfoPopup(context, 'tx'), ), @@ -249,7 +250,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.arrow_downward, value: appState.pingStats.rxCount, - color: Colors.blue, + color: PingColors.rx, onTap: () => _showInfoPopup(context, 'rx'), ), @@ -259,7 +260,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.radar, value: appState.pingStats.discCount, - color: const Color(0xFF7B68EE), // DISC purple + color: PingColors.discSuccess, onTap: () => _showInfoPopup(context, 'disc'), ), @@ -269,7 +270,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.route, value: appState.pingStats.traceCount, - color: Colors.cyan, + color: PingColors.traceSuccess, onTap: () => _showInfoPopup(context, 'trace'), ), From 2a950f3a3b6992f10e89650dfaf7c65b48c86ed1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 22 Mar 2026 20:50:48 -0400 Subject: [PATCH 02/14] - Aligned dot marker colors with web map coverage squares. Discovery markers changed from purple to cyan, RX markers changed from blue to purple. TX (green) and Trace (cyan) were already aligned. Applies to map markers, noise floor chart, status bar chips, log screen filters, and home screen stats. - Map coverage dots now draw in chronological order instead of by category. When your path crosses over itself, the most recent ping always renders on top regardless of type. - Reduced the thick white outline on map coverage dots to a subtle semi-transparent border, improving readability when markers cluster together. Repeater ID markers retain their original styling. --- lib/widgets/map_widget.dart | 206 ++++++++++++++++-------------------- 1 file changed, 89 insertions(+), 117 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 9d4a3db..8190e59 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -681,24 +681,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { tileProvider: SilentCancellableNetworkTileProvider(), ), - // TX markers (green) + // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top MarkerLayer( - markers: _buildTxMarkers(appState.txPings), - ), - - // RX markers (colored by repeater) - MarkerLayer( - markers: _buildRxMarkers(appState.rxPings), - ), - - // DISC markers (purple circles for discovery observations) - MarkerLayer( - markers: _buildDiscMarkers(appState.discLogEntries, appState.discDropEnabled), - ), - - // Trace markers (cyan/red circles for targeted ping results) - MarkerLayer( - markers: _buildTraceMarkers(appState.traceLogEntries), + markers: _buildCoverageMarkers( + txPings: appState.txPings, + rxPings: appState.rxPings, + discEntries: appState.discLogEntries, + discDropEnabled: appState.discDropEnabled, + traceEntries: appState.traceLogEntries, + ), ), // Repeater markers (magenta with ID, rotate with map) @@ -1661,119 +1652,100 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - List _buildTxMarkers(List pings) { - return pings.map((ping) { - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showTxPingDetails(ping), - child: Container( - decoration: BoxDecoration( - color: ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - // Simple dot - no arrow (looks good at any map rotation) + /// Shared decoration for coverage dots — diminished border for readability. + BoxDecoration _coverageDotDecoration(Color color) => BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 1), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], + ); + + /// Build all coverage dot markers sorted by timestamp (oldest first = drawn underneath). + /// Newer pings always render on top regardless of type. + List _buildCoverageMarkers({ + required List txPings, + required List rxPings, + required List discEntries, + required bool discDropEnabled, + required List traceEntries, + }) { + final timestamped = <(DateTime, Marker)>[ + for (final ping in txPings) + (ping.timestamp, _buildTxMarker(ping)), + for (final ping in rxPings) + (ping.timestamp, _buildRxMarker(ping)), + for (final entry in discEntries) + (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), + for (final entry in traceEntries) + (entry.timestamp, _buildTraceMarker(entry)), + ]; + + timestamped.sort((a, b) => a.$1.compareTo(b.$1)); + return timestamped.map((e) => e.$2).toList(); + } + + Marker _buildTxMarker(TxPing ping) { + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTxPingDetails(ping), + child: Container( + decoration: _coverageDotDecoration( + ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, ), ), - ); - }).toList(); + ), + ); } - List _buildRxMarkers(List pings) { - return pings.map((ping) { - // Use purple to match RX coverage squares on web map - const color = PingColors.rx; - - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showRxPingDetails(ping), - child: Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - // Simple dot - no arrow (looks good at any map rotation) - ), + Marker _buildRxMarker(RxPing ping) { + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showRxPingDetails(ping), + child: Container( + decoration: _coverageDotDecoration(PingColors.rx), ), - ); - }).toList(); + ), + ); } - List _buildDiscMarkers(List entries, bool discDropEnabled) { - return entries.map((entry) { - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showDiscPingDetails(entry), - child: Container( - decoration: BoxDecoration( - color: entry.nodeCount == 0 - ? (discDropEnabled ? Colors.red : Colors.grey) - : _discMarkerColor, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), + Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showDiscPingDetails(entry), + child: Container( + decoration: _coverageDotDecoration( + entry.nodeCount == 0 + ? (discDropEnabled ? Colors.red : Colors.grey) + : _discMarkerColor, ), ), - ); - }).toList(); + ), + ); } - List _buildTraceMarkers(List entries) { - return entries.map((entry) { - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showTraceDetails(entry), - child: Container( - decoration: BoxDecoration( - color: entry.success ? Colors.cyan : Colors.grey, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), + Marker _buildTraceMarker(TraceLogEntry entry) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTraceDetails(entry), + child: Container( + decoration: _coverageDotDecoration( + entry.success ? Colors.cyan : Colors.grey, ), ), - ); - }).toList(); + ), + ); } void _showTraceDetails(TraceLogEntry entry) { From e2cef8fd10ac2450cc1f76d9e47d6bbe0d298070 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 22 Mar 2026 21:02:10 -0400 Subject: [PATCH 03/14] - Fixed inconsistent audio playback on Android where notification sounds (TX/RX pings) would work reliably in some sessions but not at all in others. --- lib/services/audio_service.dart | 121 ++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 41898ca..e45f20e 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -11,11 +11,19 @@ import '../utils/debug_logger_io.dart'; class AudioService { static const String _prefsBoxName = 'audio_preferences'; static const String _enabledKey = 'sound_enabled'; + static const String _txAsset = 'assets/transmitted_packet.mp3'; + static const String _rxAsset = 'assets/received_packet.mp3'; + + /// Delay before releasing audio focus after the last sound plays. + /// Prevents rapid activate/deactivate cycles that break Android audio, + /// while still releasing focus for Android Auto ducking. + static const Duration _focusReleaseDelay = Duration(seconds: 3); AudioPlayer? _txPlayer; AudioPlayer? _rxPlayer; bool _initialized = false; bool _enabled = false; // Disabled by default, remembered once user changes it + Timer? _focusReleaseTimer; /// Whether the audio service is initialized bool get isInitialized => _initialized; @@ -66,9 +74,8 @@ class AudioService { _rxPlayer = AudioPlayer(); // Pre-load the audio assets for instant playback - // Using asset:// prefix for just_audio - await _txPlayer!.setAsset('assets/transmitted_packet.mp3'); - await _rxPlayer!.setAsset('assets/received_packet.mp3'); + await _txPlayer!.setAsset(_txAsset); + await _rxPlayer!.setAsset(_rxAsset); _initialized = true; debugLog('[AUDIO] Audio service initialized, enabled=$_enabled'); @@ -147,55 +154,77 @@ class AudioService { /// Play the transmit sound (when TX ping or Discovery request is sent) Future playTransmitSound() async { - debugLog('[AUDIO] playTransmitSound called - initialized=$_initialized, enabled=$_enabled'); - if (!_initialized || !_enabled) { - debugLog('[AUDIO] playTransmitSound skipped - not initialized or disabled'); - return; - } + await _playSound(_txPlayer, _txAsset, 'TX'); + } + + /// Play the receive sound (when repeater echo or RX observation is detected) + Future playReceiveSound() async { + await _playSound(_rxPlayer, _rxAsset, 'RX'); + } + + /// Shared playback logic for both TX and RX sounds. + /// Ensures audio session is active before playing and debounces focus release. + Future _playSound(AudioPlayer? player, String assetPath, String label) async { + if (!_initialized || !_enabled || player == null) return; try { - debugLog('[AUDIO] Playing transmit sound...'); - // Seek to start and play with timeout to prevent indefinite hangs - // (iOS audio session corruption can cause play() to never complete) - await _txPlayer?.seek(Duration.zero); - await _txPlayer?.play().timeout(const Duration(seconds: 3)); - debugLog('[AUDIO] Transmit sound played successfully'); - // Release audio focus after playback completes - // Critical for Android Auto - without this, car audio stays ducked - await _releaseAudioFocus(); + await _ensureSessionActive(); + await player.seek(Duration.zero); + await player.play().timeout(const Duration(seconds: 3)); + debugLog('[AUDIO] Played $label sound'); + _scheduleFocusRelease(); } on TimeoutException { - debugWarn('[AUDIO] Transmit play() timed out after 3s — resetting audio session'); - await _txPlayer?.stop(); + debugWarn('[AUDIO] $label play() timed out — resetting audio session'); + await player.stop(); await _resetAudioSession(); } catch (e) { - debugError('[AUDIO] Failed to play transmit sound: $e'); + debugError('[AUDIO] Failed to play $label sound: $e'); + // Try to recover the player for next time + try { + await player.stop(); + await player.setAsset(assetPath); + debugLog('[AUDIO] Reloaded $label player after error'); + } catch (reloadError) { + debugError('[AUDIO] Failed to reload $label player: $reloadError'); + } } } - /// Play the receive sound (when repeater echo or RX observation is detected) - Future playReceiveSound() async { - if (!_initialized || !_enabled) return; - + /// Ensure audio session is active before playback. + /// Cancels any pending focus release to prevent a race where releasing + /// focus from a previous sound kills the session for the current sound. + Future _ensureSessionActive() async { + _focusReleaseTimer?.cancel(); try { - // Seek to start and play with timeout to prevent indefinite hangs - await _rxPlayer?.seek(Duration.zero); - await _rxPlayer?.play().timeout(const Duration(seconds: 3)); - debugLog('[AUDIO] Played receive sound'); - // Release audio focus after playback completes - // Critical for Android Auto - without this, car audio stays ducked - await _releaseAudioFocus(); - } on TimeoutException { - debugWarn('[AUDIO] Receive play() timed out after 3s — resetting audio session'); - await _rxPlayer?.stop(); - await _resetAudioSession(); + final session = await AudioSession.instance; + await session.setActive(true); } catch (e) { - debugError('[AUDIO] Failed to play receive sound: $e'); + debugError('[AUDIO] Failed to activate audio session: $e'); + // Continue anyway — playback may still work } } + /// Schedule a delayed audio focus release. + /// Debounced: if another sound plays within the delay window, the timer + /// resets so focus stays active throughout rapid TX→RX sequences. + /// Critical for Android Auto: eventually releases ducking so car audio resumes. + void _scheduleFocusRelease() { + _focusReleaseTimer?.cancel(); + _focusReleaseTimer = Timer(_focusReleaseDelay, () async { + try { + final session = await AudioSession.instance; + await session.setActive(false); + debugLog('[AUDIO] Audio focus released (debounced)'); + } catch (e) { + debugError('[AUDIO] Failed to release audio focus: $e'); + } + }); + } + /// Reset audio session after a play() timeout /// Stops both players, reconfigures the audio session, and reloads assets Future _resetAudioSession() async { + _focusReleaseTimer?.cancel(); try { // Stop both players await _txPlayer?.stop(); @@ -222,8 +251,8 @@ class AudioService { ); // Reload assets so players are ready for next play() - await _txPlayer?.setAsset('assets/transmitted_packet.mp3'); - await _rxPlayer?.setAsset('assets/received_packet.mp3'); + await _txPlayer?.setAsset(_txAsset); + await _rxPlayer?.setAsset(_rxAsset); debugLog('[AUDIO] Audio session reset after timeout'); } catch (e) { @@ -231,20 +260,6 @@ class AudioService { } } - /// Release audio focus after playback completes - /// This is critical for Android Auto - without explicitly releasing focus, - /// the car audio system stays ducked indefinitely - Future _releaseAudioFocus() async { - try { - final session = await AudioSession.instance; - await session.setActive(false); - debugLog('[AUDIO] Audio focus released'); - } catch (e) { - debugError('[AUDIO] Failed to release audio focus: $e'); - // Non-critical - audio still works, just may leave other audio ducked - } - } - /// Enable or disable sound notifications Future setEnabled(bool enabled) async { if (_enabled == enabled) return; @@ -262,6 +277,8 @@ class AudioService { /// Dispose of audio resources void dispose() { debugLog('[AUDIO] Disposing audio service'); + _focusReleaseTimer?.cancel(); + _focusReleaseTimer = null; _txPlayer?.dispose(); _rxPlayer?.dispose(); _txPlayer = null; From 82b9c8684675619037ddb88362edcf9e1f5f0d62 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 22 Mar 2026 21:49:05 -0400 Subject: [PATCH 04/14] - When a zone reports TX capacity full or TX not allowed, Send Ping and Active/Hybrid mode buttons are now hidden instead of showing disabled "Zone Full" states. The Passive mode button expands to fill the full control panel width. Applies to portrait, compact, and landscape layouts. --- lib/widgets/ping_controls.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 805417d..c05b96f 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -86,6 +86,7 @@ class PingControls extends StatelessWidget { // Action buttons row Row( children: [ + if (!txNotAllowed) ...[ // Send Ping button // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" // Manual pings use 15-second cooldown, no distance requirement @@ -169,6 +170,7 @@ class PingControls extends StatelessWidget { ), ), const SizedBox(width: 10), + ], // Passive Mode button (toggle) // When ON: shows "Listening..." → "Next Disc Xs" cycle @@ -934,6 +936,7 @@ class _CompactPingControlsState extends State { // - Grey non-expanded buttons are icon-only return Row( children: [ + if (!txNotAllowed) ...[ // Send Ping - expanded buttons stay big even when grey (cooldown) if (sendPingExpanded) Expanded(child: sendPingButton) @@ -951,6 +954,7 @@ class _CompactPingControlsState extends State { else activeModeButton, const SizedBox(width: 6), + ], // Passive Mode if (passiveModeExpanded) @@ -1167,6 +1171,7 @@ class LandscapePingControls extends StatelessWidget { // Action buttons row (icon-only) Row( children: [ + if (!txNotAllowed) ...[ // TX Ping button Expanded( child: _LandscapeIconButton( @@ -1219,6 +1224,7 @@ class LandscapePingControls extends StatelessWidget { ), ), const SizedBox(width: 8), + ], // Passive Mode button Expanded( From 416e54ad23b47dc21e5ccdafcd2e2877c61fc976 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 23 Mar 2026 21:35:34 -0400 Subject: [PATCH 05/14] - Fixed an error when switching from Offline Mode back to Online Mode while connected. The app would display "Missing contact_uri or iata for registration" instead of resuming online wardriving. The mode switch now correctly re-establishes your zone before authenticating with the server. If zone detection fails (e.g., poor GPS or no internet), a clear error message is shown instead. --- lib/providers/app_state_provider.dart | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index e090f55..12ca363 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3062,6 +3062,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } + // Re-check zone status BEFORE auth (zone data was cleared when entering offline mode) + debugLog('[APP] Re-checking zone status before auth...'); + await checkZoneStatus(); + + if (zoneCode == null) { + debugError('[APP] Cannot switch to online mode: not in a zone'); + _modeSwitchError = 'Could not determine your zone. Check GPS and internet connection.'; + return (success: false, error: _modeSwitchError); + } + // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ @@ -3189,12 +3199,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await ChannelService.setRegionalChannels(_regionalChannels); } - // 7. Re-check zone status - if (_currentPosition != null) { - debugLog('[GEOFENCE] Re-checking zone status after online mode enabled'); - await checkZoneStatus(); - } - debugLog('[APP] Successfully switched to online mode'); return (success: true, error: null); } catch (e) { From 5d347ed175421f49250440dd8b552f7175e89400 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 25 Mar 2026 20:23:09 -0400 Subject: [PATCH 06/14] - Map Marker Style: choose between four coverage marker shapes under Settings > General. Dot (default, subtle border), Outlined Dot (prominent white border), Pin (teardrop), and Diamond. - GPS Marker: choose your position icon on the map. Arrow (default), Car, Bike, Boat, or Walk. Arrow and Walk rotate with heading; vehicle icons stay upright. - Sound Notifications: now under Settings > General with independent toggles for "Ping Sent" (TX/Discovery) and "Response Received" (repeater echo/RX). --- lib/models/user_preferences.dart | 22 +- lib/providers/app_state_provider.dart | 14 + lib/screens/settings_screen.dart | 160 +++++++++- lib/services/audio_service.dart | 47 ++- lib/widgets/map_widget.dart | 411 ++++++++++++++++++++++++-- 5 files changed, 613 insertions(+), 41 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 4063fc8..e750ed4 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -85,6 +85,12 @@ class UserPreferences { /// Show top 3 repeaters by SNR on the map during wardriving final bool showTopRepeaters; + /// Coverage marker style on the map (dot, pin, diamond) + final String markerStyle; + + /// GPS position marker style (arrow, car, bike, boat, walk) + final String gpsMarkerStyle; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -114,6 +120,8 @@ class UserPreferences { this.minPingDistanceMeters = 25, this.autoStopAfterIdle = true, this.showTopRepeaters = false, + this.markerStyle = 'dot', + this.gpsMarkerStyle = 'arrow', }); /// Create from JSON (for persistence) @@ -147,6 +155,8 @@ class UserPreferences { minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, + markerStyle: (json['markerStyle'] as String?) ?? 'dot', + gpsMarkerStyle: (json['gpsMarkerStyle'] as String?) ?? 'arrow', ); } @@ -181,6 +191,8 @@ class UserPreferences { 'minPingDistanceMeters': minPingDistanceMeters, 'autoStopAfterIdle': autoStopAfterIdle, 'showTopRepeaters': showTopRepeaters, + 'markerStyle': markerStyle, + 'gpsMarkerStyle': gpsMarkerStyle, }; } @@ -214,6 +226,8 @@ class UserPreferences { int? minPingDistanceMeters, bool? autoStopAfterIdle, bool? showTopRepeaters, + String? markerStyle, + String? gpsMarkerStyle, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -244,6 +258,8 @@ class UserPreferences { minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, + markerStyle: markerStyle ?? this.markerStyle, + gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, ); } @@ -302,7 +318,9 @@ class UserPreferences { other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && other.minPingDistanceMeters == minPingDistanceMeters && other.autoStopAfterIdle == autoStopAfterIdle && - other.showTopRepeaters == showTopRepeaters; + other.showTopRepeaters == showTopRepeaters && + other.markerStyle == markerStyle && + other.gpsMarkerStyle == gpsMarkerStyle; } @override @@ -335,6 +353,8 @@ class UserPreferences { minPingDistanceMeters, autoStopAfterIdle, showTopRepeaters, + markerStyle, + gpsMarkerStyle, ]); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 12ca363..19bbb36 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -487,6 +487,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Audio service getters bool get isSoundEnabled => _audioService.isEnabled; + bool get isTxSoundEnabled => _audioService.isTxEnabled; + bool get isRxSoundEnabled => _audioService.isRxEnabled; AudioService get audioService => _audioService; bool get isConnected => _connectionStep == ConnectionStep.connected; @@ -3758,6 +3760,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Set TX sound enabled state (ping sent / discovery sent) + Future setTxSoundEnabled(bool enabled) async { + await _audioService.setTxEnabled(enabled); + notifyListeners(); + } + + /// Set RX sound enabled state (repeater echo / RX observation) + Future setRxSoundEnabled(bool enabled) async { + await _audioService.setRxEnabled(enabled); + notifyListeners(); + } + /// Navigate to coordinates on map (triggered from log entries) void navigateToMapCoordinates(double latitude, double longitude) { _mapNavigationTarget = (lat: latitude, lon: longitude); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c99446f..0a52b5e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -168,6 +168,43 @@ class _SettingsScreenState extends State { ), if (!kIsWeb) _BackgroundModeToggle(appState: appState), + ListTile( + leading: const Icon(Icons.place), + title: const Text('Map Marker Style'), + subtitle: Text(_markerStyleLabel(prefs.markerStyle)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showMarkerStyleSelector(context, appState), + ), + ListTile( + leading: const Icon(Icons.my_location), + title: const Text('GPS Marker'), + subtitle: Text(_gpsMarkerLabel(prefs.gpsMarkerStyle)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showGpsMarkerSelector(context, appState), + ), + SwitchListTile( + secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + if (appState.isSoundEnabled) ...[ + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Ping Sent'), + subtitle: const Text('Sound when TX ping or discovery is sent'), + value: appState.isTxSoundEnabled, + onChanged: (value) => appState.setTxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Response Received'), + subtitle: const Text('Sound when repeater echo or RX is received'), + value: appState.isRxSoundEnabled, + onChanged: (value) => appState.setRxSoundEnabled(value), + ), + ], ]), // Ping Settings @@ -207,13 +244,6 @@ class _SettingsScreenState extends State { enabled: !isAutoMode, onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), ), - SwitchListTile( - secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), - title: const Text('Sound Notifications'), - subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), - value: appState.isSoundEnabled, - onChanged: (_) => appState.toggleSoundEnabled(), - ), SwitchListTile( secondary: const Icon(Icons.timer_off), title: const Text('Auto-Stop After Idle'), @@ -879,6 +909,122 @@ class _SettingsScreenState extends State { ); } + String _markerStyleLabel(String style) { + switch (style) { + case 'circle': return 'Outlined Dot'; + case 'pin': return 'Pin'; + case 'diamond': return 'Diamond'; + case 'dot': + default: return 'Dot'; + } + } + + String _gpsMarkerLabel(String style) { + switch (style) { + case 'car': return 'Car'; + case 'bike': return 'Bike'; + case 'boat': return 'Boat'; + case 'walk': return 'Walk'; + case 'arrow': + default: return 'Arrow'; + } + } + + void _showMarkerStyleSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('dot', 'Dot', Icons.circle), + ('circle', 'Outlined Dot', Icons.circle_outlined), + ('pin', 'Pin', Icons.place), + ('diamond', 'Diamond', Icons.diamond), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Map Marker Style', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.markerStyle, + onChanged: (v) { + if (v != null) { + appState.updatePreferences(appState.preferences.copyWith(markerStyle: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, icon) in options) + RadioListTile( + secondary: Icon(icon), + title: Text(label), + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + void _showGpsMarkerSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('arrow', 'Arrow', Icons.navigation), + ('car', 'Car', Icons.directions_car), + ('bike', 'Bike', Icons.directions_bike), + ('boat', 'Boat', Icons.directions_boat), + ('walk', 'Walk', Icons.directions_walk), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('GPS Marker', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.gpsMarkerStyle, + onChanged: (v) { + if (v != null) { + appState.updatePreferences(appState.preferences.copyWith(gpsMarkerStyle: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, icon) in options) + RadioListTile( + secondary: Icon(icon), + title: Text(label), + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + Widget _buildSection(BuildContext context, String title, List children) { return Padding( padding: const EdgeInsets.only(bottom: 8), diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index e45f20e..0abd449 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -11,6 +11,8 @@ import '../utils/debug_logger_io.dart'; class AudioService { static const String _prefsBoxName = 'audio_preferences'; static const String _enabledKey = 'sound_enabled'; + static const String _txEnabledKey = 'tx_sound_enabled'; + static const String _rxEnabledKey = 'rx_sound_enabled'; static const String _txAsset = 'assets/transmitted_packet.mp3'; static const String _rxAsset = 'assets/received_packet.mp3'; @@ -23,6 +25,8 @@ class AudioService { AudioPlayer? _rxPlayer; bool _initialized = false; bool _enabled = false; // Disabled by default, remembered once user changes it + bool _txEnabled = true; // TX sound sub-toggle (only matters when master is on) + bool _rxEnabled = true; // RX sound sub-toggle (only matters when master is on) Timer? _focusReleaseTimer; /// Whether the audio service is initialized @@ -31,6 +35,12 @@ class AudioService { /// Whether sound notifications are enabled bool get isEnabled => _enabled; + /// Whether TX sound is enabled (ping sent / discovery sent) + bool get isTxEnabled => _txEnabled; + + /// Whether RX sound is enabled (repeater echo / RX observation) + bool get isRxEnabled => _rxEnabled; + /// Initialize the audio service and pre-load sounds Future initialize() async { if (_initialized) return; @@ -99,9 +109,15 @@ class AudioService { } else { debugLog('[AUDIO] No saved preference, using default: $_enabled'); } + + final txEnabled = box.get(_txEnabledKey); + if (txEnabled != null) _txEnabled = txEnabled as bool; + final rxEnabled = box.get(_rxEnabledKey); + if (rxEnabled != null) _rxEnabled = rxEnabled as bool; + debugLog('[AUDIO] Loaded sub-toggles: tx=$_txEnabled, rx=$_rxEnabled'); } catch (e) { debugError('[AUDIO] Failed to load enabled state: $e'); - // Keep default (disabled) + // Keep defaults } } @@ -154,11 +170,13 @@ class AudioService { /// Play the transmit sound (when TX ping or Discovery request is sent) Future playTransmitSound() async { + if (!_txEnabled) return; await _playSound(_txPlayer, _txAsset, 'TX'); } /// Play the receive sound (when repeater echo or RX observation is detected) Future playReceiveSound() async { + if (!_rxEnabled) return; await _playSound(_rxPlayer, _rxAsset, 'RX'); } @@ -274,6 +292,33 @@ class AudioService { await setEnabled(!_enabled); } + /// Enable or disable TX sound notifications + Future setTxEnabled(bool enabled) async { + if (_txEnabled == enabled) return; + _txEnabled = enabled; + debugLog('[AUDIO] TX sound ${enabled ? 'enabled' : 'disabled'}'); + await _saveSetting(_txEnabledKey, enabled); + } + + /// Enable or disable RX sound notifications + Future setRxEnabled(bool enabled) async { + if (_rxEnabled == enabled) return; + _rxEnabled = enabled; + debugLog('[AUDIO] RX sound ${enabled ? 'enabled' : 'disabled'}'); + await _saveSetting(_rxEnabledKey, enabled); + } + + /// Save a single setting to Hive + Future _saveSetting(String key, dynamic value) async { + final box = await _openBoxSafely(_prefsBoxName); + if (box == null) return; + try { + await box.put(key, value); + } catch (e) { + debugError('[AUDIO] Failed to save $key: $e'); + } + } + /// Dispose of audio resources void dispose() { debugLog('[AUDIO] Disposing audio service'); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 8190e59..cacc784 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1652,13 +1652,41 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// Shared decoration for coverage dots — diminished border for readability. - BoxDecoration _coverageDotDecoration(Color color) => BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 1), - boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], - ); + /// Build a coverage marker child widget based on the user's marker style preference. + Widget _buildCoverageMarkerChild(Color color) { + final style = context.read().preferences.markerStyle; + switch (style) { + case 'circle': + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2.0), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], + ), + ); + case 'pin': + return CustomPaint( + size: const Size(20, 20), + painter: _PinMarkerPainter(color), + ); + case 'diamond': + return CustomPaint( + size: const Size(20, 20), + painter: _DiamondMarkerPainter(color), + ); + case 'dot': + default: + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withValues(alpha: 0.6), width: 1.5), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], + ), + ); + } + } /// Build all coverage dot markers sorted by timestamp (oldest first = drawn underneath). /// Newer pings always render on top regardless of type. @@ -1691,10 +1719,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { height: 20, child: GestureDetector( onTap: () => _showTxPingDetails(ping), - child: Container( - decoration: _coverageDotDecoration( - ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, - ), + child: _buildCoverageMarkerChild( + ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, ), ), ); @@ -1707,9 +1733,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { height: 20, child: GestureDetector( onTap: () => _showRxPingDetails(ping), - child: Container( - decoration: _coverageDotDecoration(PingColors.rx), - ), + child: _buildCoverageMarkerChild(PingColors.rx), ), ); } @@ -1721,12 +1745,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { height: 20, child: GestureDetector( onTap: () => _showDiscPingDetails(entry), - child: Container( - decoration: _coverageDotDecoration( - entry.nodeCount == 0 - ? (discDropEnabled ? Colors.red : Colors.grey) - : _discMarkerColor, - ), + child: _buildCoverageMarkerChild( + entry.nodeCount == 0 + ? (discDropEnabled ? Colors.red : Colors.grey) + : _discMarkerColor, ), ), ); @@ -1739,10 +1761,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { height: 20, child: GestureDetector( onTap: () => _showTraceDetails(entry), - child: Container( - decoration: _coverageDotDecoration( - entry.success ? Colors.cyan : Colors.grey, - ), + child: _buildCoverageMarkerChild( + entry.success ? Colors.cyan : Colors.grey, ), ), ); @@ -2100,15 +2120,28 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Convert heading from degrees to radians // heading is 0-360 degrees, 0 = North, 90 = East final headingRadians = heading * (math.pi / 180); + final style = context.read().preferences.gpsMarkerStyle; + + // Arrow and walk rotate with heading; vehicle/boat icons don't (they face up) + final shouldRotate = style == 'arrow' || style == 'walk'; + + final CustomPainter painter; + switch (style) { + case 'car': + painter = const _CarMarkerPainter(); + case 'bike': + painter = const _BikeMarkerPainter(); + case 'boat': + painter = const _BoatMarkerPainter(); + case 'walk': + painter = const _WalkMarkerPainter(); + case 'arrow': + default: + painter = const _ArrowPainter(); + } - // Clean directional arrow - return Transform.rotate( - angle: headingRadians, - child: CustomPaint( - size: const Size(24, 24), - painter: _ArrowPainter(), - ), - ); + final child = CustomPaint(size: const Size(24, 24), painter: painter); + return shouldRotate ? Transform.rotate(angle: headingRadians, child: child) : child; } /// Compute node column width based on hop byte count. @@ -3030,6 +3063,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Paints a crisp directional arrow pointing up class _ArrowPainter extends CustomPainter { + const _ArrowPainter(); + @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); @@ -3067,6 +3102,318 @@ class _ArrowPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } +/// Paints a car silhouette for GPS position marker +class _CarMarkerPainter extends CustomPainter { + const _CarMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + // White outline + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + // Car body outline (rounded rect, slightly larger) + final outlineRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy), width: 14, height: 20), + const Radius.circular(4), + ); + canvas.drawRRect(outlineRect, outlinePaint); + + // Blue car body + final bodyPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + final bodyRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy), width: 11, height: 17), + const Radius.circular(3), + ); + canvas.drawRRect(bodyRect, bodyPaint); + + // Windshield (darker blue rectangle near top) + final windshieldPaint = Paint() + ..color = const Color(0xFF1565C0) + ..style = PaintingStyle.fill; + final windshieldRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy - 3), width: 7, height: 4), + const Radius.circular(1), + ); + canvas.drawRRect(windshieldRect, windshieldPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a bicycle silhouette for GPS position marker +class _BikeMarkerPainter extends CustomPainter { + const _BikeMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + + final bikePaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Two wheels + const wheelR = 4.0; + final leftWheel = Offset(cx - 5, cy + 3); + final rightWheel = Offset(cx + 5, cy + 3); + + // White outlines for wheels + canvas.drawCircle(leftWheel, wheelR + 1, outlinePaint); + canvas.drawCircle(rightWheel, wheelR + 1, outlinePaint); + + // Frame outline + final framePath = ui.Path() + ..moveTo(leftWheel.dx, leftWheel.dy) + ..lineTo(cx, cy - 5) // Up to handlebars + ..lineTo(rightWheel.dx, rightWheel.dy) // Down to rear + ..moveTo(cx, cy - 5) + ..lineTo(cx + 2, cy - 7); // Handlebar + canvas.drawPath(framePath, Paint()..color = Colors.white..style = PaintingStyle.stroke..strokeWidth = 3.5..strokeCap = StrokeCap.round); + + // Blue wheels + canvas.drawCircle(leftWheel, wheelR, bikePaint); + canvas.drawCircle(rightWheel, wheelR, bikePaint); + + // Blue frame + canvas.drawPath(framePath, bikePaint); + + // Seat dot + canvas.drawCircle(Offset(cx - 1, cy - 4), 1.5, fillPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a boat silhouette for GPS position marker +class _BoatMarkerPainter extends CustomPainter { + const _BoatMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + // White outline + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Hull outline (wider) + final hullOutline = ui.Path() + ..moveTo(cx - 9, cy + 1) + ..lineTo(cx - 6, cy + 8) + ..lineTo(cx + 6, cy + 8) + ..lineTo(cx + 9, cy + 1) + ..close(); + canvas.drawPath(hullOutline, outlinePaint); + + // Hull fill + final hull = ui.Path() + ..moveTo(cx - 7, cy + 2) + ..lineTo(cx - 5, cy + 7) + ..lineTo(cx + 5, cy + 7) + ..lineTo(cx + 7, cy + 2) + ..close(); + canvas.drawPath(hull, fillPaint); + + // Mast outline + canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), + Paint()..color = Colors.white..strokeWidth = 3..strokeCap = StrokeCap.round); + // Mast + canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), + Paint()..color = const Color(0xFF2196F3)..strokeWidth = 1.5..strokeCap = StrokeCap.round); + + // Sail outline + final sailOutline = ui.Path() + ..moveTo(cx + 1, cy - 8) + ..lineTo(cx + 7, cy) + ..lineTo(cx + 1, cy) + ..close(); + canvas.drawPath(sailOutline, outlinePaint); + + // Sail + final sail = ui.Path() + ..moveTo(cx + 1, cy - 7) + ..lineTo(cx + 6, cy - 0.5) + ..lineTo(cx + 1, cy - 0.5) + ..close(); + canvas.drawPath(sail, Paint()..color = const Color(0xFF64B5F6)..style = PaintingStyle.fill); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a walking person silhouette for GPS position marker +class _WalkMarkerPainter extends CustomPainter { + const _WalkMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round; + + final personPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.8 + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Head outline + fill + canvas.drawCircle(Offset(cx, cy - 7), 3.5, Paint()..color = Colors.white..style = PaintingStyle.fill); + canvas.drawCircle(Offset(cx, cy - 7), 2.5, fillPaint); + + // Body outline + canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), outlinePaint); + // Body + canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), personPaint); + + // Arms outline + canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); + // Arms + canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); + + // Left leg outline + canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), outlinePaint); + // Right leg outline + canvas.drawLine(Offset(cx, cy + 3), Offset(cx + 4, cy + 10), outlinePaint); + // Left leg + canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), personPaint); + // Right leg + canvas.drawLine(Offset(cx, cy + 3), Offset(cx + 4, cy + 10), personPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a teardrop/pin marker for coverage dots +class _PinMarkerPainter extends CustomPainter { + final Color color; + const _PinMarkerPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + // White outline + final outlinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.7) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + // Fill + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + // Shadow + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.12) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5); + + // Teardrop: circle at top, pointed at bottom + final path = ui.Path() + ..moveTo(cx, cy + 9) // Bottom point + ..quadraticBezierTo(cx - 9, cy - 2, cx - 6, cy - 5) // Left curve + ..arcToPoint( + Offset(cx + 6, cy - 5), + radius: const Radius.circular(6), + clockwise: true, + ) // Top arc + ..quadraticBezierTo(cx + 9, cy - 2, cx, cy + 9) // Right curve + ..close(); + + canvas.drawPath(path, shadowPaint); + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, outlinePaint); + + // Inner dot for the pin head + canvas.drawCircle(Offset(cx, cy - 3), 2.5, Paint()..color = Colors.white.withValues(alpha: 0.8)); + } + + @override + bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => oldDelegate.color != color; +} + +/// Paints a diamond marker for coverage dots +class _DiamondMarkerPainter extends CustomPainter { + final Color color; + const _DiamondMarkerPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.7) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.12) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5); + + final path = ui.Path() + ..moveTo(cx, cy - 8) // Top + ..lineTo(cx + 8, cy) // Right + ..lineTo(cx, cy + 8) // Bottom + ..lineTo(cx - 8, cy) // Left + ..close(); + + canvas.drawPath(path, shadowPaint); + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, outlinePaint); + } + + @override + bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => oldDelegate.color != color; +} + /// A stateful widget for sound item with play button visual feedback class _SoundItemWidget extends StatefulWidget { final IconData icon; From 67349954432482081bd5be4f756e3d860752ca6e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 25 Mar 2026 21:01:27 -0400 Subject: [PATCH 07/14] Fixed new pin marker --- lib/widgets/map_widget.dart | 52 +++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index cacc784..7135262 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -3333,41 +3333,43 @@ class _PinMarkerPainter extends CustomPainter { final cx = size.width / 2; final cy = size.height / 2; - // White outline + final fillPaint = Paint()..color = color..style = PaintingStyle.fill; final outlinePaint = Paint() - ..color = Colors.white.withValues(alpha: 0.7) + ..color = Colors.white.withValues(alpha: 0.8) ..style = PaintingStyle.stroke ..strokeWidth = 1.5; - - // Fill - final fillPaint = Paint() - ..color = color - ..style = PaintingStyle.fill; - - // Shadow final shadowPaint = Paint() - ..color = Colors.black.withValues(alpha: 0.12) + ..color = Colors.black.withValues(alpha: 0.15) ..style = PaintingStyle.fill ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5); - // Teardrop: circle at top, pointed at bottom - final path = ui.Path() - ..moveTo(cx, cy + 9) // Bottom point - ..quadraticBezierTo(cx - 9, cy - 2, cx - 6, cy - 5) // Left curve - ..arcToPoint( - Offset(cx + 6, cy - 5), - radius: const Radius.circular(6), - clockwise: true, - ) // Top arc - ..quadraticBezierTo(cx + 9, cy - 2, cx, cy + 9) // Right curve + const headRadius = 6.0; + final headCenter = Offset(cx, cy - 2); + final tipY = cy + 9; + + // Combined shadow + final pinPath = ui.Path() + ..addOval(Rect.fromCircle(center: headCenter, radius: headRadius)) + ..moveTo(cx - 4, cy + 1) + ..lineTo(cx, tipY) + ..lineTo(cx + 4, cy + 1) ..close(); + canvas.drawPath(pinPath, shadowPaint); - canvas.drawPath(path, shadowPaint); - canvas.drawPath(path, fillPaint); - canvas.drawPath(path, outlinePaint); + // Triangle point + final triPath = ui.Path() + ..moveTo(cx - 4, cy + 1) + ..lineTo(cx, tipY) + ..lineTo(cx + 4, cy + 1) + ..close(); + canvas.drawPath(triPath, fillPaint); + + // Circle head + canvas.drawCircle(headCenter, headRadius, fillPaint); + canvas.drawCircle(headCenter, headRadius, outlinePaint); - // Inner dot for the pin head - canvas.drawCircle(Offset(cx, cy - 3), 2.5, Paint()..color = Colors.white.withValues(alpha: 0.8)); + // Inner dot + canvas.drawCircle(headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); } @override From dcad9e9a0dc4f03cfb03ff1cb01c80f927a4171f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 26 Mar 2026 14:05:46 -0400 Subject: [PATCH 08/14] - Fixed iOS reconnect failures when the device drops BLE bond keys mid-session (every 13-23 min on affected devices). iOS would cache stale keys and all reconnect attempts would fail, requiring a manual forget/re-pair in Settings. The app now detects the pairing error and clears the stale bond automatically before retrying --- lib/providers/app_state_provider.dart | 26 +++++++++++++- lib/services/bluetooth/bluetooth_service.dart | 6 ++++ lib/services/bluetooth/mobile_bluetooth.dart | 36 ++++++++++++++++--- lib/services/bluetooth/web_bluetooth.dart | 5 +++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 19bbb36..9bdfa5a 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -260,6 +260,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _reconnectRestoreGeneration = 0; static const int _maxReconnectAttempts = 3; static const Duration _reconnectDelay = Duration(seconds: 3); + static const Duration _reconnectDelayAfterBondError = Duration(seconds: 5); + bool _lastReconnectWasBondError = false; // Map navigation trigger (for navigating to log entry coordinates) ({double lat, double lon})? _mapNavigationTarget; @@ -2241,6 +2243,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _cancelPendingAutoPingRestore(); _isAutoReconnecting = true; _reconnectAttempt = 0; + _lastReconnectWasBondError = false; _connectionStep = ConnectionStep.reconnecting; // Remember auto-ping state before cleanup @@ -2309,8 +2312,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); notifyListeners(); + // Use longer delay after bond errors to give iOS time to clear stale keys + final delay = _lastReconnectWasBondError ? _reconnectDelayAfterBondError : _reconnectDelay; + // Delay before attempting reconnection - _reconnectTimer = Timer(_reconnectDelay, () async { + _reconnectTimer = Timer(delay, () async { if (!_isAutoReconnecting) return; // Cancelled while waiting try { @@ -2320,6 +2326,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { debugLog('[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); + _lastReconnectWasBondError = false; _onReconnectSuccess(); } else if (_isAutoReconnecting) { // Connection failed but didn't throw - try again @@ -2331,6 +2338,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } catch (e) { debugError('[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); if (_isAutoReconnecting) { + // Check for iOS apple-code 14 (Peer removed pairing information) + // The MeshCore device cleared its bond keys — clear iOS stale bond before retrying + await _handleBondErrorIfNeeded(e); + // Reset step back to reconnecting for UI _connectionStep = ConnectionStep.reconnecting; _connectionError = null; @@ -2341,6 +2352,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); } + /// Detect iOS apple-code 14 bond errors and clear the stale bond before retry + Future _handleBondErrorIfNeeded(Object error) async { + final errorStr = error.toString(); + if (errorStr.contains('apple-code: 14') || errorStr.contains('Peer removed pairing information')) { + _lastReconnectWasBondError = true; + final deviceId = _rememberedDevice?.id; + if (deviceId != null) { + debugLog('[CONN] Bond error detected (apple-code 14) — clearing stale bond for $deviceId'); + await _bluetoothService.removeBond(deviceId); + } + } + } + /// Called when auto-reconnect succeeds void _onReconnectSuccess() { // Cancel timers diff --git a/lib/services/bluetooth/bluetooth_service.dart b/lib/services/bluetooth/bluetooth_service.dart index e1fe5eb..4702178 100644 --- a/lib/services/bluetooth/bluetooth_service.dart +++ b/lib/services/bluetooth/bluetooth_service.dart @@ -86,6 +86,12 @@ abstract class BluetoothService { /// Used for remembered devices to ensure name is available during connect void cacheDeviceInfo(DiscoveredDevice device); + /// Remove BLE bond/pairing for a device + /// On Android: removes the system bond entry + /// On iOS: best-effort — calls cancelPeripheralConnection to nudge CoreBluetooth + /// into clearing stale encryption keys (used after apple-code 14 errors) + Future removeBond(String deviceId); + /// Dispose of resources void dispose(); } diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index d279477..71e7dfa 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -397,13 +397,26 @@ class MobileBluetoothService implements BluetoothService { return; // Success - exit retry loop } catch (e, stackTrace) { + final errorStr = e.toString(); + // Check for Android error 133 (GATT_ERROR) - a well-known Android BLE stack issue // that typically succeeds on retry - final isError133 = Platform.isAndroid && e.toString().contains('android-code: 133'); - - if (isError133 && attempt < _maxRetries) { - debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); - await Future.delayed(_retryDelay); + final isError133 = Platform.isAndroid && errorStr.contains('android-code: 133'); + + // Check for iOS apple-code 14 (Peer removed pairing information) + // The remote device cleared its bond keys — clear iOS stale bond and retry + final isBondError = Platform.isIOS && + (errorStr.contains('apple-code: 14') || errorStr.contains('Peer removed pairing information')); + + if ((isError133 || isBondError) && attempt < _maxRetries) { + if (isBondError) { + debugLog('[BLE] Bond error (apple-code 14) on attempt $attempt, removing bond and retrying...'); + await removeBond(deviceId); + await Future.delayed(const Duration(seconds: 2)); + } else { + debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); + await Future.delayed(_retryDelay); + } // Force cleanup before retry try { await _bleDevice?.disconnect(); @@ -470,6 +483,19 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] Cached device info: ${device.name} (${device.id})'); } + @override + Future removeBond(String deviceId) async { + try { + final device = fbp.BluetoothDevice.fromId(deviceId); + debugLog('[BLE] Removing bond for $deviceId'); + await device.removeBond(); + debugLog('[BLE] Bond removed for $deviceId'); + } catch (e) { + // removeBond may not be supported on all platforms/devices — log and continue + debugLog('[BLE] removeBond failed (continuing): $e'); + } + } + @override void dispose() { _isDisposed = true; diff --git a/lib/services/bluetooth/web_bluetooth.dart b/lib/services/bluetooth/web_bluetooth.dart index 81054ed..ef5ed4c 100644 --- a/lib/services/bluetooth/web_bluetooth.dart +++ b/lib/services/bluetooth/web_bluetooth.dart @@ -262,6 +262,11 @@ class WebBluetoothService implements BluetoothService { // No caching needed - this method is for mobile remembered devices } + @override + Future removeBond(String deviceId) async { + // Web Bluetooth does not support bond management + } + @override void dispose() { _notificationSubscription?.cancel(); From b4c514ca62098b153f332c01a14051b27389fdec Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 26 Mar 2026 20:00:47 -0400 Subject: [PATCH 09/14] =?UTF-8?q?=E2=8F=BA=20TX=20Power=20Reporting:=20Rem?= =?UTF-8?q?oved=20radio=20power=20from=20the=20BLE=20mesh=20message=20([0.?= =?UTF-8?q?3w])=20and=20moved=20it=20to=20a=20per-ping=20power=20field=20i?= =?UTF-8?q?n=20every=20API=20wardrive=20data=20post=20(TX,=20RX,=20DISC,?= =?UTF-8?q?=20=20=20TRACE).=20Previously,=20the=20power=20value=20embedded?= =?UTF-8?q?=20in=20the=20mesh=20broadcast=20fell=20back=20to=200.3W=20for?= =?UTF-8?q?=20devices=20not=20in=20the=20model=20database,=20causing=20inc?= =?UTF-8?q?orrect=20wattage=20in=20the=20admin=20=20=20panel=20even=20when?= =?UTF-8?q?=20the=20user=20had=20configured=20the=20correct=20power.=20Pow?= =?UTF-8?q?er=20is=20now=20read=20from=20user=20preferences=20and=20sent?= =?UTF-8?q?=20directly=20to=20the=20API=20with=20each=20ping,=20ensuring?= =?UTF-8?q?=20accurate=20=20=20reporting=20regardless=20of=20device=20mode?= =?UTF-8?q?l=20recognition.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/models/api_queue_item.dart | 19 +++++++++++++++++++ lib/providers/app_state_provider.dart | 4 ++++ lib/services/api_queue_service.dart | 10 ++++++++++ lib/services/meshcore/connection.dart | 9 ++++----- lib/services/ping_service.dart | 18 +++++++++++------- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 337cab1..0f58d31 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -52,6 +52,10 @@ class ApiQueueItem extends HiveObject { @HiveField(14) final bool externalAntenna; + /// Radio power in watts (e.g., 0.3, 1.0, 2.0) — included in every API post + @HiveField(15) + final double? power; + ApiQueueItem({ required this.type, required this.latitude, @@ -63,6 +67,7 @@ class ApiQueueItem extends HiveObject { this.retryCount = 0, this.lastRetryAt, this.noiseFloor, + this.power, }); /// Create from TX ping @@ -74,6 +79,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'TX', @@ -84,6 +90,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -96,6 +103,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'RX', @@ -106,6 +114,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -123,6 +132,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" final heardRepeats = '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; @@ -135,6 +145,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -150,6 +161,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; return ApiQueueItem( @@ -161,6 +173,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -171,6 +184,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'DISC', @@ -181,6 +195,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -201,6 +216,7 @@ class ApiQueueItem extends HiveObject { 'remote_snr': parts.length > 3 ? double.tryParse(parts[3]) : null, 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -216,6 +232,7 @@ class ApiQueueItem extends HiveObject { 'repeater_id': 'None', 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -234,6 +251,7 @@ class ApiQueueItem extends HiveObject { 'public_key': parts.length > 5 ? parts[5] : '', 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -245,6 +263,7 @@ class ApiQueueItem extends HiveObject { 'heard_repeats': heardRepeats, 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 9bdfa5a..a6fc3e0 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1314,6 +1314,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Get external antenna value for API payloads _pingService!.getExternalAntenna = () => _preferences.externalAntenna; + // Get power level from preferences (includes per-device overrides and manual selection) + _pingService!.getPowerLevel = () => _preferences.powerLevel; + // Check if TX is allowed by API (zone capacity) _pingService!.checkTxAllowed = () => txAllowed; @@ -1959,6 +1962,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaterId: entry.repeaterId, externalAntenna: _preferences.externalAntenna, noiseFloor: _meshCoreConnection?.lastNoiseFloor, + power: _preferences.powerLevel, ); // Update UI diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 2cb45fa..eb8b70f 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -223,6 +223,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromTx( latitude: latitude, @@ -231,6 +232,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -266,6 +268,7 @@ class ApiQueueService { required String repeaterId, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromRx( latitude: latitude, @@ -274,6 +277,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -309,6 +313,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromDisc( latitude: latitude, @@ -322,6 +327,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -358,6 +364,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromTrace( latitude: latitude, @@ -369,6 +376,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -401,6 +409,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromDiscDrop( latitude: latitude, @@ -408,6 +417,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index fd0f389..ba2fbd5 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -950,9 +950,10 @@ class MeshCoreConnection { } /// Send ping to #wardriving channel - /// Format: @[MapperBot] LAT, LON [power] + /// Format: @[MapperBot] LAT, LON + /// Power is no longer included in the mesh message — it is sent per-ping in the API payload instead /// Reference: buildPayload() in wardrive.js - Future sendPing(double lat, double lon, double powerWatts) async { + Future sendPing(double lat, double lon) async { final channel = _wardrivingChannel; if (channel == null) { throw Exception('Wardriving channel not initialized'); @@ -960,9 +961,7 @@ class MeshCoreConnection { // Format coordinates to 5 decimal places with comma separator final coordsStr = '${lat.toStringAsFixed(5)}, ${lon.toStringAsFixed(5)}'; - // Format power as "X.Xw" (e.g., "1.0w", "0.3w") - final powerStr = '${powerWatts.toStringAsFixed(1)}w'; - final message = '@[MapperBot] $coordsStr [$powerStr]'; + final message = '@[MapperBot] $coordsStr'; debugLog('[CONN] Sending ping: $message'); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index d782282..02e3f22 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -137,6 +137,9 @@ class PingService { /// Callback to get the external antenna value for API payloads bool Function()? getExternalAntenna; + /// Callback to get the power level in watts (0.3, 0.6, 1.0, 2.0) from user preferences + double Function()? getPowerLevel; + /// Callback to check if discovery drop is enabled (failed discoveries → API) bool Function()? getDiscDropEnabled; @@ -531,15 +534,12 @@ class PingService { _pingInProgress = false; return false; } - // Use power in watts (0.3, 0.6, 1.0, 2.0) - matches web client buildPayload() - final powerWatts = _connection.deviceModel?.power ?? 0.3; - // Also get txPower in dBm for API queue (for database records) final txPowerDbm = _connection.deviceModel?.txPower ?? 22; // Build ping message (same format used for TxTracker correlation) + // Power is no longer included in the mesh message — sent per-ping in API payload final coordsStr = '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; - final powerStr = '${powerWatts.toStringAsFixed(1)}w'; - final pingMessage = '@[MapperBot] $coordsStr [$powerStr]'; + final pingMessage = '@[MapperBot] $coordsStr'; // Capture noise floor at ping time final noiseFloor = _connection.lastNoiseFloor; @@ -620,8 +620,8 @@ class PingService { // Play transmit sound immediately before sending _audioService?.playTransmitSound(); - // Send ping via BLE - uses watts format like "1.0w" - await _connection.sendPing(position.latitude, position.longitude, powerWatts); + // Send ping via BLE (coordinates only — power is in API payload) + await _connection.sendPing(position.latitude, position.longitude); // Mark ping time and position _lastTxTime = DateTime.now(); @@ -725,6 +725,7 @@ class PingService { timestamp: txTimestamp, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); debugLog('[PING] Queued TX entry with heard_repeats: $heardRepeats'); @@ -1205,6 +1206,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); } @@ -1222,6 +1224,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); debugLog('[DISC] Discovery drop queued (no response)'); } @@ -1471,6 +1474,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); // Update stats From 948bb47aa1a675cba32abc7c1deacecfcc514306 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 26 Mar 2026 22:01:28 -0400 Subject: [PATCH 10/14] Fix hybrid mode fallback, discovery window timing, connection lifecycle races, and auto-ping guards - Hybrid mode: route blocked TX pings back to hybrid scheduler instead of active-only - Discovery window: increase from 5s to 7s, fix hybrid interval math - Add apple-code 15 to bond error detection - Replace session deadline timer with 15-min idle disconnect timer - Enable heartbeat immediately on connect, keep alive while connected - Add idempotency guard to _fullDisconnectCleanup() for double-call race - Add _disposed flag and connect() guard for controller-closed race - Add reconnect/connection guards to sendPing, sendTxPing, _sendDiscoveryRequest, and all auto-ping timer callbacks - Throttle geofence logs to 1 per 30s with suppressed count --- lib/providers/app_state_provider.dart | 94 ++++++++++++++++++-- lib/services/api_service.dart | 37 +------- lib/services/bluetooth/mobile_bluetooth.dart | 8 +- lib/services/meshcore/connection.dart | 25 ++++-- lib/services/meshcore/disc_tracker.dart | 2 +- lib/services/ping_service.dart | 42 +++++++-- 6 files changed, 149 insertions(+), 59 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a6fc3e0..5745097 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -263,6 +263,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { static const Duration _reconnectDelayAfterBondError = Duration(seconds: 5); bool _lastReconnectWasBondError = false; + // Idle disconnect timer — disconnects after 15 min without manual ping or auto-ping + Timer? _idleDisconnectTimer; + static const Duration _idleDisconnectTimeout = Duration(minutes: 15); + + // Geofence zone check log throttle (while disconnected) + DateTime? _lastZoneCheckLogTime; + int _zoneCheckSuppressedCount = 0; + // Map navigation trigger (for navigating to log entry coordinates) ({double lat, double lon})? _mapNavigationTarget; int _mapNavigationTrigger = 0; // Increment to trigger navigation @@ -744,7 +752,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // This allows users to know if they've entered/exited a zone while moving // Skip zone checks when offline mode is enabled if (!isConnected && !_preferences.offlineMode && _shouldRecheckZone(position)) { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + // Throttle log to once per 30s to avoid spam while driving + final now = DateTime.now(); + if (_lastZoneCheckLogTime == null || now.difference(_lastZoneCheckLogTime!) >= const Duration(seconds: 30)) { + if (_zoneCheckSuppressedCount > 0) { + debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); + } else { + debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + } + _lastZoneCheckLogTime = now; + _zoneCheckSuppressedCount = 0; + } else { + _zoneCheckSuppressedCount++; + } await checkZoneStatus(); } @@ -1714,6 +1734,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!_preferences.offlineMode) { _startZoneRefreshTimer(); } + + // Enable heartbeat immediately on connection to keep server session alive + // Previously only enabled on auto-ping start, causing silent session expiry + if (!_preferences.offlineMode && _apiService.hasSession) { + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + debugLog('[HEARTBEAT] Enabled on connection'); + } + + // Start 15-minute idle disconnect timer (cancelled by manual ping or auto-ping start) + _startIdleDisconnectTimer(); } else { // No API session - offline mode or auth skipped debugLog('[CONN] Connected without API session (offline mode)'); @@ -2175,6 +2211,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } Future _fullDisconnectCleanup() async { + // Guard against double cleanup (e.g., reconnect timeout + BLE disconnect event) + if (_connectionStep == ConnectionStep.disconnected) { + debugLog('[CONN] Already disconnected, skipping duplicate cleanup'); + return; + } _cancelPendingAutoPingRestore(); _connectionStep = ConnectionStep.disconnected; @@ -2245,6 +2286,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start auto-reconnect after unexpected BLE disconnect Future _startAutoReconnect() async { _cancelPendingAutoPingRestore(); + _cancelIdleDisconnectTimer(); _isAutoReconnecting = true; _reconnectAttempt = 0; _lastReconnectWasBondError = false; @@ -2356,14 +2398,36 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); } - /// Detect iOS apple-code 14 bond errors and clear the stale bond before retry + /// Start 15-minute idle disconnect timer. + /// Fires if user does not send a manual ping or start auto-ping within 15 minutes. + void _startIdleDisconnectTimer() { + _idleDisconnectTimer?.cancel(); + _idleDisconnectTimer = Timer(_idleDisconnectTimeout, () { + if (!isConnected || _autoPingEnabled) return; + debugLog('[IDLE] 15-minute idle timeout reached — disconnecting'); + logError('Disconnected: 15 minutes of inactivity', severity: ErrorSeverity.warning); + disconnect(); + }); + debugLog('[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); + } + + /// Cancel the idle disconnect timer + void _cancelIdleDisconnectTimer() { + if (_idleDisconnectTimer != null) { + _idleDisconnectTimer!.cancel(); + _idleDisconnectTimer = null; + debugLog('[IDLE] Idle disconnect timer cancelled'); + } + } + + /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry Future _handleBondErrorIfNeeded(Object error) async { final errorStr = error.toString(); - if (errorStr.contains('apple-code: 14') || errorStr.contains('Peer removed pairing information')) { + if (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')) { _lastReconnectWasBondError = true; final deviceId = _rememberedDevice?.id; if (deviceId != null) { - debugLog('[CONN] Bond error detected (apple-code 14) — clearing stale bond for $deviceId'); + debugLog('[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); await _bluetoothService.removeBond(deviceId); } } @@ -2408,6 +2472,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } }); + } else { + // No auto-ping to restore — start idle timer + _startIdleDisconnectTimer(); } notifyListeners(); @@ -2453,6 +2520,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Mark as user-requested so BLE disconnect listener doesn't trigger auto-reconnect _userRequestedDisconnect = true; + // Cancel idle disconnect timer + _cancelIdleDisconnectTimer(); + // Cancel any active auto-reconnect _reconnectTimer?.cancel(); _reconnectTimer = null; @@ -2631,6 +2701,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Send a manual TX ping Future sendPing() async { if (_pingService == null) return false; + if (_isAutoReconnecting) { + debugLog('[PING] Ignoring ping during auto-reconnect'); + return false; + } // Check session validity before starting (skip in offline mode) if (!_preferences.offlineMode) { @@ -2638,6 +2712,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!sessionCheck) return false; } + // Reset idle disconnect timer (user is actively pinging) + _startIdleDisconnectTimer(); + // Set sending state immediately for instant UI feedback _isPingSending = true; notifyListeners(); @@ -2736,8 +2813,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // End noise floor session when mode is disabled await _endNoiseFloorSession(); - // Disable heartbeat when stopping auto mode - _apiService.disableHeartbeat(); + // Keep heartbeat enabled (stays on while connected to prevent session expiry) + // Re-start idle disconnect timer now that user is idle again + _startIdleDisconnectTimer(); _autoPingEnabled = false; _idleAutoStopReference = null; @@ -2754,6 +2832,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } } else { + // Cancel idle disconnect timer — auto-ping keeps the session active + _cancelIdleDisconnectTimer(); + // Check session validity before starting (skip in offline mode) if (!_preferences.offlineMode) { final sessionCheck = await _checkSessionBeforeAction(); @@ -5134,6 +5215,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer?.cancel(); _reconnectTimeoutTimer?.cancel(); _restoreAutoPingTimer?.cancel(); + _idleDisconnectTimer?.cancel(); _offlineAutoSaveTimer?.cancel(); _zoneRefreshTimer?.cancel(); _tileRefreshTimer?.cancel(); diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 064c25a..2da5d0c 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -40,7 +40,7 @@ class ApiService { int? _sessionExpiresAt; Timer? _heartbeatTimer; Timer? _heartbeatRetryTimer; - Timer? _sessionDeadlineTimer; + int _heartbeatRetryCount = 0; static const int _maxHeartbeatRetries = 5; Function? _onSessionExpiring; @@ -607,8 +607,6 @@ class ApiService { _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; _heartbeatRetryCount = 0; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; debugLog('[HEARTBEAT] Heartbeat mode disabled'); } @@ -642,9 +640,6 @@ class ApiService { _sendScheduledHeartbeat(); }); } - - // Schedule session deadline timer at exact expiry - _scheduleSessionDeadline(expiresAt); } /// Send scheduled heartbeat with GPS coordinates @@ -693,32 +688,6 @@ class ApiService { } } - /// Schedule a hard deadline timer at the exact session expiry time. - /// If the server is unreachable and all heartbeat retries fail, this fires - /// and triggers the same disconnect flow as a server-returned session_expired. - void _scheduleSessionDeadline(int expiresAt) { - _sessionDeadlineTimer?.cancel(); - if (!_heartbeatEnabled) return; - - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final secondsUntilExpiry = expiresAt - now; - - if (secondsUntilExpiry <= 0) { - _onSessionDeadlineReached(); - return; - } - - debugLog('[HEARTBEAT] Session deadline set for ${secondsUntilExpiry}s from now'); - _sessionDeadlineTimer = Timer(Duration(seconds: secondsUntilExpiry), _onSessionDeadlineReached); - } - - /// Called when the session deadline timer fires — server was unreachable - void _onSessionDeadlineReached() { - debugError('[HEARTBEAT] Session deadline reached - server unreachable, triggering session expiry'); - _clearSession(); - onSessionError?.call('session_expired', 'Session has timed out (server unreachable)'); - } - /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; @@ -736,8 +705,6 @@ class ApiService { _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; _heartbeatRetryCount = 0; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; debugLog('[API] Session cleared'); } @@ -982,8 +949,6 @@ class ApiService { _heartbeatTimer = null; _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; _client.close(); } } diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 71e7dfa..8fb3d62 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -403,14 +403,14 @@ class MobileBluetoothService implements BluetoothService { // that typically succeeds on retry final isError133 = Platform.isAndroid && errorStr.contains('android-code: 133'); - // Check for iOS apple-code 14 (Peer removed pairing information) - // The remote device cleared its bond keys — clear iOS stale bond and retry + // Check for iOS apple-code 14 (Peer removed pairing information) or + // apple-code 15 (Failed to encrypt the connection) — both indicate stale bond keys final isBondError = Platform.isIOS && - (errorStr.contains('apple-code: 14') || errorStr.contains('Peer removed pairing information')); + (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')); if ((isError133 || isBondError) && attempt < _maxRetries) { if (isBondError) { - debugLog('[BLE] Bond error (apple-code 14) on attempt $attempt, removing bond and retrying...'); + debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); await removeBond(deviceId); await Future.delayed(const Duration(seconds: 2)); } else { diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index ba2fbd5..5f7513d 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -65,6 +65,7 @@ class SelfInfo { /// 9. Connected State class MeshCoreConnection { final BluetoothService _bluetooth; + bool _disposed = false; final _stepController = StreamController.broadcast(); final _channelMessageController = StreamController.broadcast(); final _rawDataController = StreamController>.broadcast(); @@ -177,8 +178,8 @@ class MeshCoreConnection { void _updateStep(ConnectionStep step) { _currentStep = step; - if (_stepController.isClosed) { - debugError('[CONN] Cannot update step - controller is closed!'); + if (_disposed || _stepController.isClosed) { + debugLog('[CONN] Ignoring step update on disposed connection (expected during reconnect)'); return; } debugLog('[CONN] Step: $step'); @@ -189,8 +190,11 @@ class MeshCoreConnection { /// Returns (deviceModel, deviceModelMatched) for display/reporting purposes /// Note: This method does NOT modify radio TX power settings - it only reads device info Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect(String deviceId, List deviceModels) async { + if (_disposed) { + throw Exception('Connection instance has been disposed'); + } bool deviceModelMatched = false; - + try { // Step 1: BLE Connect _updateStep(ConnectionStep.bleConnecting); @@ -655,10 +659,16 @@ class MeshCoreConnection { if (statsType == StatsTypes.radio) { final noiseFloor = reader.readInt16LE(); // Skip remaining fields (lastRssi, lastSnr, txAirSecs, rxAirSecs) - _lastNoiseFloor = noiseFloor; - _noiseFloorController.add(noiseFloor); // Emit to stream - debugLog('[CONN] Noise floor updated: ${noiseFloor}dBm'); - _statsCompleter?.complete(noiseFloor); + if (noiseFloor == 0) { + // MeshCore 1.14.x AGC reset zeroes out noise floor briefly; discard + debugLog('[CONN] Noise floor reading is 0dBm (AGC reset), ignoring'); + _statsCompleter?.complete(0); + } else { + _lastNoiseFloor = noiseFloor; + _noiseFloorController.add(noiseFloor); // Emit to stream + debugLog('[CONN] Noise floor updated: ${noiseFloor}dBm'); + _statsCompleter?.complete(noiseFloor); + } } else { debugLog('[CONN] Unknown stats type: $statsType'); _statsCompleter?.complete(0); @@ -1180,6 +1190,7 @@ class MeshCoreConnection { /// Dispose of resources void dispose() { + _disposed = true; _stopNoiseFloorPolling(); _stopBatteryPolling(); _setTimeCompleter = null; diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index 5796d6f..23dd9d6 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -45,7 +45,7 @@ class DiscTracker { /// @param windowDuration - How long to listen (default 7 seconds) void startTracking({ required Uint8List tag, - Duration windowDuration = const Duration(seconds: 5), + Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[DISC] Starting discovery tracking'); debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 02e3f22..033aaa4 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -44,8 +44,8 @@ class PingService { static const Duration _rxListeningWindow = Duration(seconds: 5); /// Cooldown period between pings (5 seconds) static const Duration _autoPingCooldown = Duration(seconds: 5); - /// Discovery listening window duration (5 seconds) - static const Duration _discoveryListeningWindow = Duration(seconds: 5); + /// Discovery listening window duration (7 seconds) + static const Duration _discoveryListeningWindow = Duration(seconds: 7); /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); /// Cooldown period between manual pings (15 seconds) @@ -474,6 +474,13 @@ class PingService { Future sendTxPing({bool manual = true}) async { debugLog('[PING] sendTxPing called (manual=$manual)'); + // Guard: don't send pings if connection is not in connected state + // Handles race where timer callback fires after reconnect started + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[PING] Ignoring TX ping — not connected (step: ${_connection.currentStep})'); + return false; + } + // Early guard: prevent concurrent ping execution (critical for preventing BLE GATT errors) // Reference: state.pingInProgress check in wardrive.js if (_pingInProgress) { @@ -518,7 +525,11 @@ class PingService { _skipReason = 'too close'; debugLog('[PING] Auto ping blocked: too close to last ping, scheduling next'); } - _scheduleNextAutoPing(); + if (_hybridModeEnabled) { + _scheduleNextHybridPing(); + } else { + _scheduleNextAutoPing(); + } } _pingInProgress = false; return false; @@ -809,6 +820,11 @@ class PingService { _autoTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { debugLog('[ACTIVE MODE] Auto ping timer fired'); + // Guard: connection may have dropped since timer was scheduled + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[ACTIVE MODE] Not connected, ignoring timer'); + return; + } // Double-check guards before sending ping if (!_autoPingEnabled || _passiveModeEnabled) { debugLog('[ACTIVE MODE] Auto mode no longer running, ignoring timer'); @@ -1082,6 +1098,12 @@ class PingService { /// Send a discovery request and start listening window Future _sendDiscoveryRequest() async { + // Guard: don't send discovery during reconnect (race with timer queue) + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[DISC] Ignoring discovery request — not connected (step: ${_connection.currentStep})'); + return; + } + if (!_autoPingEnabled || (!_passiveModeEnabled && !_hybridModeEnabled)) { debugLog('[DISC] Not in Passive/Hybrid Mode, skipping discovery request'); return; @@ -1278,8 +1300,10 @@ class PingService { _autoTimer = null; // Subtract listening window so interval is measured start-to-start - // At 15s: wait = 15000 - 5000 = 10000ms. Clamp to min 1s. - final listenMs = _rxListeningWindow.inMilliseconds; // 5000 + // TX uses 5s RX window, discovery uses 7s window + final listenMs = _nextPingIsDiscovery + ? _discoveryListeningWindow.inMilliseconds + : _rxListeningWindow.inMilliseconds; final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; @@ -1289,6 +1313,10 @@ class PingService { _autoTimer = Timer(Duration(milliseconds: waitMs), () { if (!_autoPingEnabled || !_hybridModeEnabled) return; + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[HYBRID] Not connected, ignoring timer'); + return; + } if (_pingInProgress) { debugLog('[HYBRID] Ping already in progress, skipping'); return; @@ -1504,6 +1532,10 @@ class PingService { _targetedTimer?.cancel(); _targetedTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { debugLog('[TRACE] Targeted ping timer fired'); + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[TRACE] Not connected, ignoring timer'); + return; + } if (_autoPingEnabled && _targetedModeEnabled) { if (_pingInProgress) { debugLog('[TRACE] Ping already in progress, skipping'); From e632d65b723a27b793520bd9202cfc8adebc57cd Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 27 Mar 2026 21:48:38 -0400 Subject: [PATCH 11/14] =?UTF-8?q?=20Colorblind=20Accessibility=20Setting?= =?UTF-8?q?=20=E2=80=94=20COMPLETED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status: Implementation complete, pending commit Approach Convert PingColors from static const fields to static get properties backed by a swappable palette. Add a colorVisionType user preference. All 7+ files that reference PingColors.txSuccess etc. continue to work without call-site changes -- the getters return the active palette's colors. CVD Types Supported ┌───────────────┬────────────────────────────────────┐ │ Setting │ Description │ ├───────────────┼────────────────────────────────────┤ │ none │ Default (current colors) │ ├───────────────┼────────────────────────────────────┤ │ protanopia │ Red-blind -- confuses red/green │ ├───────────────┼────────────────────────────────────┤ │ deuteranopia │ Green-blind -- confuses red/green │ ├───────────────┼────────────────────────────────────┤ │ tritanopia │ Blue-blind -- confuses blue/yellow │ ├───────────────┼────────────────────────────────────┤ │ achromatopsia │ Total color blindness (monochrome) │ └───────────────┴────────────────────────────────────┘ Palettes (Wong 2011 colorblind-safe colors) Normal -- current app colors unchanged. Protanopia / Deuteranopia (both use same palette since both are red-green CVD): - txSuccess: #0072B2 (blue) -- replaces green - txFail: #D55E00 (vermillion) -- replaces red - txSuccessLegend: #56B4E9 (sky blue) - rx: #CC79A7 (reddish purple) - discSuccess: #56B4E9 (sky blue) - traceSuccess: #009E73 (bluish green) - signalGood/Medium/Bad: #0072B2 / #F0E442 / #D55E00 - repeaterActive/New/Dead/Duplicate: #CC79A7 / #F0E442 / #9E9E9E / #D55E00 Tritanopia: - txSuccess: #009E73 (bluish green) -- green visible - txFail: #D55E00 (vermillion) - txSuccessLegend: #22C55E - rx: #CC79A7 (reddish purple) - discSuccess: #E69F00 (orange) -- replaces cyan - traceSuccess: #D55E00 (vermillion) -- replaces cyan - signalGood/Medium/Bad: #009E73 / #E69F00 / #D55E00 - repeaterActive/New/Dead/Duplicate: #CC79A7 / #E69F00 / #9E9E9E / #D55E00 Achromatopsia: - txSuccess: #E0E0E0 (light) / txFail: #616161 (dark) - txSuccessLegend: #E0E0E0 - rx: #9E9E9E (medium) - discSuccess: #BDBDBD (medium-light) / traceSuccess: #757575 (medium-dark) - signalGood/Medium/Bad: #E0E0E0 / #9E9E9E / #424242 - repeaterActive/New/Dead/Duplicate: #E0E0E0 / #BDBDBD / #616161 / #424242 --- lib/models/user_preferences.dart | 12 +- lib/providers/app_state_provider.dart | 20 ++ lib/screens/home_screen.dart | 4 +- lib/screens/log_screen.dart | 29 +-- lib/screens/settings_screen.dart | 68 ++++++- lib/utils/ping_colors.dart | 257 +++++++++++++++++++++++-- lib/widgets/map_widget.dart | 151 +++------------ lib/widgets/noise_floor_chart.dart | 43 ++--- lib/widgets/repeater_id_chip.dart | 27 ++- lib/widgets/repeater_picker_sheet.dart | 27 ++- 10 files changed, 425 insertions(+), 213 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index e750ed4..8275d26 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -91,6 +91,9 @@ class UserPreferences { /// GPS position marker style (arrow, car, bike, boat, walk) final String gpsMarkerStyle; + /// Color vision type for accessibility (none, protanopia, deuteranopia, tritanopia, achromatopsia) + final String colorVisionType; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -122,6 +125,7 @@ class UserPreferences { this.showTopRepeaters = false, this.markerStyle = 'dot', this.gpsMarkerStyle = 'arrow', + this.colorVisionType = 'none', }); /// Create from JSON (for persistence) @@ -157,6 +161,7 @@ class UserPreferences { showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, markerStyle: (json['markerStyle'] as String?) ?? 'dot', gpsMarkerStyle: (json['gpsMarkerStyle'] as String?) ?? 'arrow', + colorVisionType: (json['colorVisionType'] as String?) ?? 'none', ); } @@ -193,6 +198,7 @@ class UserPreferences { 'showTopRepeaters': showTopRepeaters, 'markerStyle': markerStyle, 'gpsMarkerStyle': gpsMarkerStyle, + 'colorVisionType': colorVisionType, }; } @@ -228,6 +234,7 @@ class UserPreferences { bool? showTopRepeaters, String? markerStyle, String? gpsMarkerStyle, + String? colorVisionType, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -260,6 +267,7 @@ class UserPreferences { showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, markerStyle: markerStyle ?? this.markerStyle, gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, + colorVisionType: colorVisionType ?? this.colorVisionType, ); } @@ -320,7 +328,8 @@ class UserPreferences { other.autoStopAfterIdle == autoStopAfterIdle && other.showTopRepeaters == showTopRepeaters && other.markerStyle == markerStyle && - other.gpsMarkerStyle == gpsMarkerStyle; + other.gpsMarkerStyle == gpsMarkerStyle && + other.colorVisionType == colorVisionType; } @override @@ -355,6 +364,7 @@ class UserPreferences { showTopRepeaters, markerStyle, gpsMarkerStyle, + colorVisionType, ]); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 5745097..2cbd544 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -37,6 +37,7 @@ import '../services/meshcore/unified_rx_handler.dart'; import '../services/ping_service.dart'; import '../services/countdown_timer_service.dart'; import '../utils/constants.dart'; +import '../utils/ping_colors.dart'; import '../services/wakelock_service.dart'; import '../utils/debug_logger_io.dart'; @@ -3817,6 +3818,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _savePreferences(); } + /// Set color vision type for accessibility and persist + void setColorVisionType(String type) { + _preferences = _preferences.copyWith(colorVisionType: type); + PingColors.setColorVisionType( + ColorVisionType.values.firstWhere((e) => e.name == type, orElse: () => ColorVisionType.none), + ); + debugLog('[A11Y] Color vision type set to $type'); + notifyListeners(); + _savePreferences(); + } + /// Set unit system preference (metric or imperial) void setUnitSystem(String system) { _preferences = _preferences.copyWith(unitSystem: system); @@ -4837,6 +4849,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Apply saved min ping distance to GpsService and PingService _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = _preferences.minPingDistanceMeters; + + // Apply saved color vision type + PingColors.setColorVisionType( + ColorVisionType.values.firstWhere( + (e) => e.name == _preferences.colorVisionType, + orElse: () => ColorVisionType.none, + ), + ); } } catch (e) { debugLog('[APP] Failed to load preferences: $e'); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 2e0150f..0064326 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -999,9 +999,7 @@ class _HomeScreenState extends State { /// Get color based on noise floor value (lower is better) Color _getNoiseFloorColor(int noiseFloor) { - if (noiseFloor <= -100) return Colors.green; // -100 to -120: great - if (noiseFloor <= -90) return Colors.orange; // -90 to -100: okay - return Colors.red; // 0 to -90: bad + return PingColors.noiseFloorColor(noiseFloor.toDouble()); } /// Get battery icon based on percentage diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 5c09b5e..565f392 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -809,14 +809,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { final rxSnrColor = _snrColorFromValue(node.localSnr); final rssiColor = _rssiColor(node.localRssi); - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), @@ -831,7 +824,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, - style: const TextStyle( + style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: PingColors.discSuccess, @@ -985,29 +978,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { static Color _snrColor(SnrSeverity? severity) { return switch (severity) { - SnrSeverity.poor => Colors.red, - SnrSeverity.fair => Colors.orange, - SnrSeverity.good => Colors.green, + SnrSeverity.poor => PingColors.signalBad, + SnrSeverity.fair => PingColors.signalMedium, + SnrSeverity.good => PingColors.signalGood, null => Colors.grey, }; } - static Color _snrColorFromValue(double snr) { - if (snr <= -1) return Colors.red; - if (snr <= 5) return Colors.orange; - return Colors.green; - } + static Color _snrColorFromValue(double snr) => PingColors.snrColor(snr); static Color _snrColorFromNullableValue(double? snr) { if (snr == null) return Colors.grey; - return _snrColorFromValue(snr); + return PingColors.snrColor(snr); } static Color _rssiColor(int? rssi) { if (rssi == null) return Colors.grey; - if (rssi >= -70) return Colors.green; - if (rssi >= -100) return Colors.orange; - return Colors.red; + return PingColors.rssiColor(rssi); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 0a52b5e..a1278ca 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -146,6 +146,15 @@ class _SettingsScreenState extends State { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), + if (!kIsWeb) + _BackgroundModeToggle(appState: appState), + ListTile( + leading: const Icon(Icons.visibility), + title: const Text('Color Vision'), + subtitle: Text(_colorVisionLabel(prefs.colorVisionType)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showColorVisionSelector(context, appState), + ), SwitchListTile( secondary: Icon( prefs.isImperial ? Icons.square_foot : Icons.straighten, @@ -166,8 +175,6 @@ class _SettingsScreenState extends State { appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); }, ), - if (!kIsWeb) - _BackgroundModeToggle(appState: appState), ListTile( leading: const Icon(Icons.place), title: const Text('Map Marker Style'), @@ -1025,6 +1032,63 @@ class _SettingsScreenState extends State { ); } + String _colorVisionLabel(String type) { + return switch (type) { + 'protanopia' => 'Protanopia (red-blind)', + 'deuteranopia' => 'Deuteranopia (green-blind)', + 'tritanopia' => 'Tritanopia (blue-blind)', + 'achromatopsia' => 'Achromatopsia (monochrome)', + _ => 'Default', + }; + } + + void _showColorVisionSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('none', 'Default', 'Standard color palette'), + ('protanopia', 'Protanopia', 'Red-blind — difficulty distinguishing red and green'), + ('deuteranopia', 'Deuteranopia', 'Green-blind — difficulty distinguishing red and green'), + ('tritanopia', 'Tritanopia', 'Blue-blind — difficulty distinguishing blue and yellow'), + ('achromatopsia', 'Achromatopsia', 'Total color blindness — sees in greyscale'), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Color Vision', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.colorVisionType, + onChanged: (v) { + if (v != null) appState.setColorVisionType(v); + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, subtitle) in options) + RadioListTile( + secondary: const Icon(Icons.visibility), + title: Text(label), + subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null, + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + Widget _buildSection(BuildContext context, String title, List children) { return Padding( padding: const EdgeInsets.only(bottom: 8), diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart index 2587a93..7e72421 100644 --- a/lib/utils/ping_colors.dart +++ b/lib/utils/ping_colors.dart @@ -1,29 +1,250 @@ import 'package:flutter/material.dart'; +import '../utils/debug_logger_io.dart'; -/// Centralized color constants for ping types (TX, RX, DISC, Trace). +/// Color vision deficiency types for accessibility. /// -/// Dot/marker colors are aligned with coverage layer squares on the -/// MeshMapper web map: -/// BIDIR=#7EE094, TX=#FD8928, DISC/TRACE=#51D4E9, RX=#7D54C7, -/// DEAD=#9E9689, DROP=#E04F5D +/// Users select their CVD type in Settings > General > Color Vision. +/// The app adapts all semantic colors (ping types, signal quality, +/// repeater status, noise floor) to a distinguishable palette. +enum ColorVisionType { + none, // Default — current palette + protanopia, // Red-blind (~1% males) + deuteranopia, // Green-blind (~1% males) + tritanopia, // Blue-blind (~0.003%) + achromatopsia, // Total color blindness (monochrome) +} + +/// Immutable palette holding every semantic color the app uses. +class ColorPalette { + // Ping type colors + final Color txSuccess; + final Color txSuccessLegend; + final Color txFail; + final Color rx; + final Color discSuccess; + final Color discFail; + final Color traceSuccess; + final Color noResponse; + + // Signal quality (SNR/RSSI) traffic-light + final Color signalGood; + final Color signalMedium; + final Color signalBad; + + // Repeater status on map + final Color repeaterActive; + final Color repeaterNew; + final Color repeaterDead; + final Color repeaterDuplicate; + + // Noise floor gradient (good → medium → bad) + final Color noiseFloorGood; + final Color noiseFloorMedium; + final Color noiseFloorBad; + + const ColorPalette({ + required this.txSuccess, + required this.txSuccessLegend, + required this.txFail, + required this.rx, + required this.discSuccess, + required this.discFail, + required this.traceSuccess, + required this.noResponse, + required this.signalGood, + required this.signalMedium, + required this.signalBad, + required this.repeaterActive, + required this.repeaterNew, + required this.repeaterDead, + required this.repeaterDuplicate, + required this.noiseFloorGood, + required this.noiseFloorMedium, + required this.noiseFloorBad, + }); +} + +/// Concrete palette definitions for each CVD type. +/// +/// Color choices for CVD palettes based on Wong (2011) "Points of view: +/// Color blindness" — Nature Methods. All colors within each palette are +/// mutually distinguishable for the target CVD type. +class ColorPalettes { + ColorPalettes._(); + + /// Default palette — matches original app colors and web map squares. + /// BIDIR=#7EE094, TX=#FD8928, DISC/TRACE=#51D4E9, RX=#7D54C7, + /// DEAD=#9E9689, DROP=#E04F5D + static const none = ColorPalette( + txSuccess: Color(0xFF4CAF50), + txSuccessLegend: Color(0xFF22C55E), + txFail: Color(0xFFF44336), + rx: Color(0xFF7D54C7), + discSuccess: Color(0xFF51D4E9), + discFail: Color(0xFF9E9E9E), + traceSuccess: Color(0xFF00BCD4), + noResponse: Color(0xFF9E9E9E), + signalGood: Colors.green, + signalMedium: Colors.orange, + signalBad: Colors.red, + repeaterActive: Color(0xFFA52163), + repeaterNew: Color(0xFFC05802), + repeaterDead: Colors.grey, + repeaterDuplicate: Color(0xFFA51D2A), + noiseFloorGood: Colors.green, + noiseFloorMedium: Colors.orange, + noiseFloorBad: Colors.red, + ); + + /// Protanopia (red-blind) — replaces red/green axis with blue/orange. + /// Also used for deuteranopia since both are red-green CVD. + static const protanopia = ColorPalette( + txSuccess: Color(0xFF0072B2), // Wong blue + txSuccessLegend: Color(0xFF56B4E9), // Wong sky blue + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFF56B4E9), // Wong sky blue + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFF009E73), // Wong bluish green + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF0072B2), // Blue + signalMedium: Color(0xFFF0E442), // Wong yellow + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFF0E442), // Yellow + repeaterDead: Color(0xFF9E9E9E), // Grey + repeaterDuplicate: Color(0xFFD55E00), // Vermillion + noiseFloorGood: Color(0xFF0072B2), + noiseFloorMedium: Color(0xFFF0E442), + noiseFloorBad: Color(0xFFD55E00), + ); + + /// Tritanopia (blue-blind) — replaces blue/cyan with orange/vermillion. + /// Red/green distinction is preserved since tritan users can see those. + static const tritanopia = ColorPalette( + txSuccess: Color(0xFF009E73), // Wong bluish green + txSuccessLegend: Color(0xFF22C55E), // Bright green (visible) + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFFE69F00), // Wong orange (replaces cyan) + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFFD55E00), // Vermillion (replaces cyan) + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF009E73), // Bluish green + signalMedium: Color(0xFFE69F00), // Orange + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFE69F00), // Orange + repeaterDead: Color(0xFF9E9E9E), // Grey + repeaterDuplicate: Color(0xFFD55E00), // Vermillion + noiseFloorGood: Color(0xFF009E73), + noiseFloorMedium: Color(0xFFE69F00), + noiseFloorBad: Color(0xFFD55E00), + ); + + /// Achromatopsia (monochrome) — luminance-only palette. + /// Relies on maximum brightness contrast between categories. + /// Secondary indicators (icons, text) are essential with this palette. + static const achromatopsia = ColorPalette( + txSuccess: Color(0xFFE0E0E0), // Light + txSuccessLegend: Color(0xFFE0E0E0), + txFail: Color(0xFF616161), // Dark + rx: Color(0xFF9E9E9E), // Medium + discSuccess: Color(0xFFBDBDBD), // Medium-light + discFail: Color(0xFF757575), // Medium-dark + traceSuccess: Color(0xFF757575), // Medium-dark + noResponse: Color(0xFF616161), // Dark + signalGood: Color(0xFFE0E0E0), // Light + signalMedium: Color(0xFF9E9E9E), // Medium + signalBad: Color(0xFF424242), // Very dark + repeaterActive: Color(0xFFE0E0E0), // Light + repeaterNew: Color(0xFFBDBDBD), // Medium-light + repeaterDead: Color(0xFF616161), // Dark + repeaterDuplicate: Color(0xFF424242), // Very dark + noiseFloorGood: Color(0xFFE0E0E0), + noiseFloorMedium: Color(0xFF9E9E9E), + noiseFloorBad: Color(0xFF424242), + ); + + /// Look up palette for a given CVD type. + static ColorPalette forType(ColorVisionType type) { + return switch (type) { + ColorVisionType.none => none, + ColorVisionType.protanopia => protanopia, + ColorVisionType.deuteranopia => protanopia, // Same as protanopia + ColorVisionType.tritanopia => tritanopia, + ColorVisionType.achromatopsia => achromatopsia, + }; + } +} + +/// Centralized color accessors for ping types, signal quality, repeater +/// status, and noise floor. +/// +/// All getters delegate to the active [ColorPalette], which changes when the +/// user selects a different color vision type in Settings. Existing call +/// sites (`PingColors.txSuccess`, etc.) continue to work unchanged. class PingColors { PingColors._(); - // ── TX (green — we can't distinguish BIDIR vs TX client-side) ── - static const Color txSuccess = Color(0xFF4CAF50); - static const Color txSuccessLegend = Color(0xFF22C55E); - static const Color txFail = Color(0xFFF44336); + static ColorPalette _activePalette = ColorPalettes.none; + static ColorVisionType _currentType = ColorVisionType.none; + + /// Set the active palette. Called by AppStateProvider when the + /// colorVisionType preference changes or on app startup. + static void setColorVisionType(ColorVisionType type) { + _currentType = type; + _activePalette = ColorPalettes.forType(type); + debugLog('[A11Y] Color palette set to ${type.name}'); + } + + /// Current CVD type (for UI display in settings). + static ColorVisionType get currentType => _currentType; + + // ── Ping type colors (same API as before) ── + static Color get txSuccess => _activePalette.txSuccess; + static Color get txSuccessLegend => _activePalette.txSuccessLegend; + static Color get txFail => _activePalette.txFail; + static Color get rx => _activePalette.rx; + static Color get discSuccess => _activePalette.discSuccess; + static Color get discFail => _activePalette.discFail; + static Color get traceSuccess => _activePalette.traceSuccess; + static Color get noResponse => _activePalette.noResponse; + + // ── Signal quality (SNR/RSSI traffic-light) ── + static Color get signalGood => _activePalette.signalGood; + static Color get signalMedium => _activePalette.signalMedium; + static Color get signalBad => _activePalette.signalBad; + + // ── Repeater status ── + static Color get repeaterActive => _activePalette.repeaterActive; + static Color get repeaterNew => _activePalette.repeaterNew; + static Color get repeaterDead => _activePalette.repeaterDead; + static Color get repeaterDuplicate => _activePalette.repeaterDuplicate; - // ── RX (purple — matches RX web map squares #7D54C7) ── - static const Color rx = Color(0xFF7D54C7); + // ── Noise floor gradient ── + static Color get noiseFloorGood => _activePalette.noiseFloorGood; + static Color get noiseFloorMedium => _activePalette.noiseFloorMedium; + static Color get noiseFloorBad => _activePalette.noiseFloorBad; - // ── DISC (cyan — matches DISC/TRACE web map squares #51D4E9) ── - static const Color discSuccess = Color(0xFF51D4E9); - static const Color discFail = Color(0xFF9E9E9E); + // ── Convenience: SNR color from value ── + static Color snrColor(double snr) { + if (snr <= -1) return signalBad; + if (snr <= 5) return signalMedium; + return signalGood; + } - // ── Trace (cyan family — same web map layer as DISC) ── - static const Color traceSuccess = Color(0xFF00BCD4); + // ── Convenience: RSSI color from value ── + static Color rssiColor(int rssi) { + if (rssi >= -70) return signalGood; + if (rssi >= -100) return signalMedium; + return signalBad; + } - // ── Shared ── - static const Color noResponse = Color(0xFF9E9E9E); + // ── Convenience: Noise floor color from dBm ── + static Color noiseFloorColor(double dbm) { + if (dbm <= -100) return noiseFloorGood; + if (dbm <= -90) return noiseFloorMedium; + return noiseFloorBad; + } } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7135262..2e03785 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -836,12 +836,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// SNR color: green > 5, orange -1..5, red <= -1 - static Color _snrColor(double snr) { - if (snr <= -1) return Colors.red; - if (snr <= 5) return Colors.orange; - return Colors.green; - } + /// SNR color (delegates to active palette) + static Color _snrColor(double snr) => PingColors.snrColor(snr); Widget _buildGpsInfoOverlay(AppStateProvider appState) { final position = appState.currentPosition; @@ -896,9 +892,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } Color _getAccuracyColor(double accuracy) { - if (accuracy <= 10) return Colors.green; - if (accuracy <= 30) return Colors.orange; - return Colors.red; + if (accuracy <= 10) return PingColors.signalGood; + if (accuracy <= 30) return PingColors.signalMedium; + return PingColors.signalBad; } /// Map controls (always vertical, used inside collapsible wrapper) @@ -1747,7 +1743,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { onTap: () => _showDiscPingDetails(entry), child: _buildCoverageMarkerChild( entry.nodeCount == 0 - ? (discDropEnabled ? Colors.red : Colors.grey) + ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) : _discMarkerColor, ), ), @@ -1941,32 +1937,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final localRssi = entry.localRssi ?? 0; final remoteSnr = entry.remoteSnr ?? 0; - Color rxSnrColor; - if (localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (localRssi >= -70) { - rssiColor = Colors.green; - } else if (localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final rxSnrColor = PingColors.snrColor(localSnr); + final rssiColor = PingColors.rssiColor(localRssi); + final txSnrColor = PingColors.snrColor(remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId), @@ -2019,20 +1992,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// DISC marker color (#51D4E9 - cyan, matches DISC/TRACE web map squares) - static const Color _discMarkerColor = PingColors.discSuccess; + /// DISC marker color (delegates to active palette) + static Color get _discMarkerColor => PingColors.discSuccess; - /// Repeater marker color (#a52163 - magenta/pink) - Active - static const Color _repeaterMarkerColor = Color(0xFFA52163); + /// Repeater marker color - Active (delegates to active palette) + static Color get _repeaterMarkerColor => PingColors.repeaterActive; - /// Duplicate repeater marker color (#a51d2a - red) - static const Color _repeaterDuplicateColor = Color(0xFFA51D2A); + /// Duplicate repeater marker color (delegates to active palette) + static Color get _repeaterDuplicateColor => PingColors.repeaterDuplicate; - /// New repeater marker color (#c05802 - orange) - static const Color _repeaterNewColor = Color(0xFFC05802); + /// New repeater marker color (delegates to active palette) + static Color get _repeaterNewColor => PingColors.repeaterNew; - /// Dead repeater marker color (grey) - static const Color _repeaterDeadColor = Colors.grey; + /// Dead repeater marker color (delegates to active palette) + static Color get _repeaterDeadColor => PingColors.repeaterDead; /// Get set of duplicate repeater IDs Set _getDuplicateRepeaterIds(List repeaters) { @@ -2190,11 +2163,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.15), + color: PingColors.txSuccess.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + border: Border.all(color: PingColors.txSuccess.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), + child: Icon(Icons.arrow_upward, color: PingColors.txSuccess, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2320,29 +2293,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Divider(height: 1, color: Theme.of(context).dividerColor), // Data rows ...heardRepeaters.map((repeater) { - // Calculate SNR chip color - Color snrColor; - if (repeater.snr == null) { - snrColor = Colors.grey; - } else if (repeater.snr! <= -1) { - snrColor = Colors.red; - } else if (repeater.snr! <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color - Color rssiColor; - if (repeater.rssi == null) { - rssiColor = Colors.grey; - } else if (repeater.rssi! >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi! >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + final snrColor = repeater.snr != null ? PingColors.snrColor(repeater.snr!) : Colors.grey; + final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), @@ -2389,25 +2341,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Show RX ping details popup void _showRxPingDetails(RxPing ping) { - // Calculate SNR severity for chip color - Color snrColor; - if (ping.snr <= -1) { - snrColor = Colors.red; - } else if (ping.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color based on signal strength - Color rssiColor; - if (ping.rssi >= -70) { - rssiColor = Colors.green; // Strong: -30 to -70 dBm - } else if (ping.rssi >= -100) { - rssiColor = Colors.orange; // Medium: -70 to -100 dBm - } else { - rssiColor = Colors.red; // Weak: -100 to -120 dBm - } + final snrColor = PingColors.snrColor(ping.snr); + final rssiColor = PingColors.rssiColor(ping.rssi); showModalBottomSheet( context: context, @@ -2628,7 +2563,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { borderRadius: BorderRadius.circular(12), border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), ), - child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), + child: Icon(Icons.radar, color: _discMarkerColor, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2767,33 +2702,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Divider(height: 1, color: Theme.of(context).dividerColor), // Data rows ...entry.discoveredNodes.map((node) { - // Calculate colors - Color rxSnrColor; - if (node.localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (node.localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final rxSnrColor = PingColors.snrColor(node.localSnr); + final rssiColor = PingColors.rssiColor(node.localRssi); + final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), @@ -2809,7 +2720,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), Text( node.nodeTypeLabel, - style: const TextStyle( + style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, color: _discMarkerColor, diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 8659252..568807c 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -483,25 +483,8 @@ class InteractiveNoiseFloorChartState extends State /// Build a table row for a repeater (matching TX log style) Widget _buildRepeaterRow(BuildContext context, MarkerRepeaterInfo repeater) { - // SNR color: good (>5), fair (-1 to 5), poor (<-1) - Color snrColor; - if (repeater.snr <= -1) { - snrColor = Colors.red; - } else if (repeater.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // RSSI color based on signal strength - Color rssiColor; - if (repeater.rssi >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + final snrColor = PingColors.snrColor(repeater.snr); + final rssiColor = PingColors.rssiColor(repeater.rssi); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fullHexId: repeater.pubkeyHex), @@ -703,20 +686,20 @@ class InteractiveNoiseFloorChartState extends State return ((dbm - minY) / yRange).clamp(0.0, 1.0); } - // Smooth gradient with faded transitions + // Smooth gradient with faded transitions (palette-aware) final lineColors = [ - Colors.green, - Colors.green, - Colors.orange, - Colors.red, - Colors.red, + PingColors.noiseFloorGood, + PingColors.noiseFloorGood, + PingColors.noiseFloorMedium, + PingColors.noiseFloorBad, + PingColors.noiseFloorBad, ]; final fillColors = [ - Colors.green.withValues(alpha: 0.2), - Colors.green.withValues(alpha: 0.15), - Colors.orange.withValues(alpha: 0.12), - Colors.red.withValues(alpha: 0.1), - Colors.red.withValues(alpha: 0.08), + PingColors.noiseFloorGood.withValues(alpha: 0.2), + PingColors.noiseFloorGood.withValues(alpha: 0.15), + PingColors.noiseFloorMedium.withValues(alpha: 0.12), + PingColors.noiseFloorBad.withValues(alpha: 0.1), + PingColors.noiseFloorBad.withValues(alpha: 0.08), ]; final stops = [ 0.0, diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 0088da5..d2ae8e2 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -7,6 +7,7 @@ import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// A styled repeater ID text with a dotted underline hint that it's tappable. /// @@ -201,8 +202,9 @@ class RepeaterIdChip extends StatelessWidget { int? regionHopBytesOverride, }) { final isActive = repeater.isActive; - final badgeColor = isActive ? Colors.green : Colors.grey; + final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; final statusText = isActive ? 'Active' : 'Stale'; + final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; // Calculate distance string if GPS is available String? distanceText; @@ -273,7 +275,7 @@ class RepeaterIdChip extends StatelessWidget { ), ), const SizedBox(width: 8), - // Active/Stale chip + // Active/Stale chip (icon + text for colorblind accessibility) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( @@ -281,13 +283,20 @@ class RepeaterIdChip extends StatelessWidget { borderRadius: BorderRadius.circular(10), border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), - child: Text( - statusText, - style: TextStyle( - fontSize: 11, - color: badgeColor, - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 8, color: badgeColor), + const SizedBox(width: 4), + Text( + statusText, + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ], diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart index a10fa2d..f78950b 100644 --- a/lib/widgets/repeater_picker_sheet.dart +++ b/lib/widgets/repeater_picker_sheet.dart @@ -6,6 +6,7 @@ import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// Show a bottom sheet repeater picker and return the selected repeater. Future showRepeaterPicker(BuildContext context) { @@ -226,7 +227,8 @@ class _RepeaterTile extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isActive = repeater.isActive; - final badgeColor = isActive ? Colors.green : Colors.grey; + final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; + final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; // Distance text String? distanceText; @@ -315,7 +317,7 @@ class _RepeaterTile extends StatelessWidget { ), ), const SizedBox(width: 8), - // Active/Stale chip + // Active/Stale chip (icon + text for colorblind accessibility) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( @@ -324,13 +326,20 @@ class _RepeaterTile extends StatelessWidget { border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), - child: Text( - isActive ? 'Active' : 'Stale', - style: TextStyle( - fontSize: 11, - color: badgeColor, - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 8, color: badgeColor), + const SizedBox(width: 4), + Text( + isActive ? 'Active' : 'Stale', + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ], From 01a8d5a3d130945d115ba181a0cb76f26fa97ca7 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 27 Mar 2026 22:58:47 -0400 Subject: [PATCH 12/14] - Disconnect alert: triple-beep sound when BLE drops during wardriving, so you know pings have stopped. Has its own toggle, independent from the master sound setting, so you can wardrive silently and still get alerted on disconnect --- lib/models/user_preferences.dart | 22 +++++++++++++++- lib/providers/app_state_provider.dart | 30 +++++++++++++++++++++ lib/screens/settings_screen.dart | 18 +++++++++++++ lib/services/audio_service.dart | 25 ++++++++++++++++++ lib/widgets/map_widget.dart | 38 ++++++++++++++++----------- 5 files changed, 116 insertions(+), 17 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 8275d26..3275a1f 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -94,6 +94,12 @@ class UserPreferences { /// Color vision type for accessibility (none, protanopia, deuteranopia, tritanopia, achromatopsia) final String colorVisionType; + /// Download map tiles (base map + coverage overlay). When false, no tile network requests are made to save mobile data. + final bool mapTilesEnabled; + + /// Disconnect alert: play audible alert when pinging stops unexpectedly (BLE disconnect, idle timeout, maintenance) + final bool disconnectAlertEnabled; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -126,6 +132,8 @@ class UserPreferences { this.markerStyle = 'dot', this.gpsMarkerStyle = 'arrow', this.colorVisionType = 'none', + this.mapTilesEnabled = true, + this.disconnectAlertEnabled = false, }); /// Create from JSON (for persistence) @@ -162,6 +170,8 @@ class UserPreferences { markerStyle: (json['markerStyle'] as String?) ?? 'dot', gpsMarkerStyle: (json['gpsMarkerStyle'] as String?) ?? 'arrow', colorVisionType: (json['colorVisionType'] as String?) ?? 'none', + mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, + disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, ); } @@ -199,6 +209,8 @@ class UserPreferences { 'markerStyle': markerStyle, 'gpsMarkerStyle': gpsMarkerStyle, 'colorVisionType': colorVisionType, + 'mapTilesEnabled': mapTilesEnabled, + 'disconnectAlertEnabled': disconnectAlertEnabled, }; } @@ -235,6 +247,8 @@ class UserPreferences { String? markerStyle, String? gpsMarkerStyle, String? colorVisionType, + bool? mapTilesEnabled, + bool? disconnectAlertEnabled, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -268,6 +282,8 @@ class UserPreferences { markerStyle: markerStyle ?? this.markerStyle, gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, colorVisionType: colorVisionType ?? this.colorVisionType, + mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, + disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, ); } @@ -329,7 +345,9 @@ class UserPreferences { other.showTopRepeaters == showTopRepeaters && other.markerStyle == markerStyle && other.gpsMarkerStyle == gpsMarkerStyle && - other.colorVisionType == colorVisionType; + other.colorVisionType == colorVisionType && + other.mapTilesEnabled == mapTilesEnabled && + other.disconnectAlertEnabled == disconnectAlertEnabled; } @override @@ -365,6 +383,8 @@ class UserPreferences { markerStyle, gpsMarkerStyle, colorVisionType, + mapTilesEnabled, + disconnectAlertEnabled, ]); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 2cbd544..712f8cf 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -500,6 +500,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isSoundEnabled => _audioService.isEnabled; bool get isTxSoundEnabled => _audioService.isTxEnabled; bool get isRxSoundEnabled => _audioService.isRxEnabled; + bool get isDisconnectAlertEnabled => _preferences.disconnectAlertEnabled; AudioService get audioService => _audioService; bool get isConnected => _connectionStep == ConnectionStep.connected; @@ -2232,6 +2233,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer.stop(); _cooldownTimer.stop(); if (_autoPingEnabled) { + if (!_userRequestedDisconnect) { + _playDisconnectAlert(); + } _autoPingEnabled = false; _idleAutoStopReference = null; debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); @@ -2496,6 +2500,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimeoutTimer = null; _cancelPendingAutoPingRestore(); + // Alert if auto-ping was running before disconnect + if (_autoPingWasEnabled) { + _playDisconnectAlert(); + } + // Clear reconnect state _isAutoReconnecting = false; _reconnectAttempt = 0; @@ -2756,6 +2765,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Auto-stop auto-ping after prolonged idle (no movement) void _triggerIdleAutoStop() { if (!_autoPingEnabled) return; + _playDisconnectAlert(); final elapsed = _idleAutoStopReference != null ? DateTime.now().difference(_idleAutoStopReference!).inMinutes : 30; @@ -3893,6 +3903,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Set disconnect alert enabled state + Future setDisconnectAlertEnabled(bool enabled) async { + _preferences = _preferences.copyWith(disconnectAlertEnabled: enabled); + await _savePreferences(); + debugLog('[AUDIO] Disconnect alert ${enabled ? 'enabled' : 'disabled'}'); + notifyListeners(); + } + + /// Play disconnect alert if enabled (triple beep for unexpected ping stop) + void _playDisconnectAlert() { + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) return; + debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); + _audioService.playAlertSound(); + } + /// Navigate to coordinates on map (triggered from log entries) void navigateToMapCoordinates(double latitude, double longitude) { _mapNavigationTarget = (lat: latitude, lon: longitude); @@ -4040,6 +4065,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future _handleMaintenanceModeConnected(String message, String? url) async { debugLog('[MAINTENANCE] Ending session due to maintenance mode'); + // Alert if auto-ping was running (maintenance is not user-initiated) + if (_autoPingEnabled) { + _playDisconnectAlert(); + } + // Log to error log (this sets _requestErrorLogSwitch = true) logError('Maintenance Mode Enabled: $message', severity: ErrorSeverity.warning); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index a1278ca..b001366 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -148,6 +148,17 @@ class _SettingsScreenState extends State { ), if (!kIsWeb) _BackgroundModeToggle(appState: appState), + SwitchListTile( + secondary: Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), + title: const Text('Disable Map Tiles'), + subtitle: Text(prefs.mapTilesEnabled + ? 'Map and coverage tiles load normally' + : 'Disabled to save mobile data'), + value: !prefs.mapTilesEnabled, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); + }, + ), ListTile( leading: const Icon(Icons.visibility), title: const Text('Color Vision'), @@ -211,6 +222,13 @@ class _SettingsScreenState extends State { value: appState.isRxSoundEnabled, onChanged: (value) => appState.setRxSoundEnabled(value), ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Disconnect Alert'), + subtitle: const Text('Triple beep when pinging stops unexpectedly'), + value: appState.isDisconnectAlertEnabled, + onChanged: (value) => appState.setDisconnectAlertEnabled(value), + ), ], ]), diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 0abd449..6b74c07 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -278,6 +278,31 @@ class AudioService { } } + /// Play disconnect alert sound (triple beep pattern). + /// Independent of master sound toggle — this is a safety alert. + Future playAlertSound() async { + if (!_initialized || _txPlayer == null) return; + + try { + await _ensureSessionActive(); + for (int i = 0; i < 3; i++) { + await _txPlayer!.seek(Duration.zero); + await _txPlayer!.play().timeout(const Duration(seconds: 3)); + if (i < 2) { + await Future.delayed(const Duration(milliseconds: 300)); + } + } + debugLog('[AUDIO] Played disconnect alert (triple beep)'); + _scheduleFocusRelease(); + } on TimeoutException { + debugWarn('[AUDIO] Alert play() timed out — resetting audio session'); + await _txPlayer!.stop(); + await _resetAudioSession(); + } catch (e) { + debugError('[AUDIO] Failed to play alert sound: $e'); + } + } + /// Enable or disable sound notifications Future setEnabled(bool enabled) async { if (_enabled == enabled) return; diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 2e03785..caa4b97 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -654,22 +654,24 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), children: [ // Tile layer (dynamic based on selected style from preferences) - Builder( - builder: (context) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); - return TileLayer( - urlTemplate: mapStyle.urlTemplate, - subdomains: mapStyle.subdomains ?? const [], - userAgentPackageName: 'com.meshmapper.app', - maxZoom: 17, - retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), - tileProvider: SilentCancellableNetworkTileProvider(), - ); - }, - ), + // Skipped entirely when map tiles are disabled to save mobile data + if (appState.preferences.mapTilesEnabled) + Builder( + builder: (context) { + final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + return TileLayer( + urlTemplate: mapStyle.urlTemplate, + subdomains: mapStyle.subdomains ?? const [], + userAgentPackageName: 'com.meshmapper.app', + maxZoom: 17, + retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), + tileProvider: SilentCancellableNetworkTileProvider(), + ); + }, + ), - // MeshMapper coverage overlay (only when zone code available and overlay enabled) - if (appState.zoneCode != null && _showMeshMapperOverlay) + // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) + if (appState.preferences.mapTilesEnabled && appState.zoneCode != null && _showMeshMapperOverlay) TileLayer( urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}', userAgentPackageName: 'com.meshmapper.app', @@ -701,9 +703,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), - // Current position marker (car icon) + // Current position marker if (appState.currentPosition != null) MarkerLayer( + // Vehicle/boat icons stay upright by counter-rotating against map rotation; + // arrow and walk rotate with heading (handled by Transform.rotate in the painter) + rotate: appState.preferences.gpsMarkerStyle != 'arrow' && + appState.preferences.gpsMarkerStyle != 'walk', markers: [ Marker( point: LatLng( From 499a70bdd1160d43e295f45669472718d0481709 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 27 Mar 2026 23:06:36 -0400 Subject: [PATCH 13/14] Updating the four repeater colors in the default palette to match the web --- lib/utils/ping_colors.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart index 7e72421..6455db0 100644 --- a/lib/utils/ping_colors.dart +++ b/lib/utils/ping_colors.dart @@ -87,10 +87,10 @@ class ColorPalettes { signalGood: Colors.green, signalMedium: Colors.orange, signalBad: Colors.red, - repeaterActive: Color(0xFFA52163), - repeaterNew: Color(0xFFC05802), - repeaterDead: Colors.grey, - repeaterDuplicate: Color(0xFFA51D2A), + repeaterActive: Color(0xFFD63384), + repeaterNew: Color(0xFFFD7E14), + repeaterDead: Color(0xFF6C757D), + repeaterDuplicate: Color(0xFFDC3545), noiseFloorGood: Colors.green, noiseFloorMedium: Colors.orange, noiseFloorBad: Colors.red, From 005311f23cd38337a97f478f3372e62c77824b52 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 27 Mar 2026 23:27:46 -0400 Subject: [PATCH 14/14] - Fixed auto-ping using stale GPS positions. The position stream and ping timers run independently, so the cached position could be well behind your actual location. Auto-pings now force a fresh GPS read before each ping --- lib/services/gps_service.dart | 24 ++++++++++++++++++++++++ lib/services/ping_service.dart | 11 +++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 753c67c..6eced5c 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -358,6 +358,30 @@ class GpsService { return null; // Valid } + /// Request a fresh GPS position from the hardware for auto-ping accuracy. + /// On mobile, this forces a warm-start GPS read (typically < 1 second when + /// GPS is already streaming). Falls back to lastPosition on timeout/error. + Future getFreshPosition({Duration timeout = const Duration(seconds: 3)}) async { + // Simulator provides its own positions — use cached + if (_simulatorEnabled) { + return _lastPosition; + } + + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: timeout, + ); + debugLog('[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' + '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); + _lastPosition = position; + return position; + } catch (e) { + debugLog('[GPS] Fresh position request failed, using cached: $e'); + return _lastPosition; + } + } + /// Get current position (single request) Future getCurrentPosition() async { if (!await requestPermissions()) { diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 033aaa4..50bc46e 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -490,6 +490,13 @@ class PingService { _pingInProgress = true; try { + // For auto pings, request a fresh GPS position before validation. + // This ensures the 25m distance check and ping coordinates reflect + // where the device is NOW, not where it was at the last stream event. + if (!manual) { + await _gpsService.getFreshPosition(); + } + // Use different validation and cooldown for manual vs auto pings if (manual) { // Manual ping: 15-second cooldown, no distance check @@ -1109,8 +1116,8 @@ class PingService { return; } - // Check GPS - final position = _gpsService.lastPosition; + // Request fresh GPS position before discovery (same rationale as TX auto-ping) + final position = await _gpsService.getFreshPosition(); if (position == null) { debugLog('[DISC] No GPS position, skipping discovery request'); _pingInProgress = false;