diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1df1988..389fc29 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1967,8 +1967,21 @@ fan-out logic (`MeshMapper_Server/dev/index.php`): Reuses the points fetched in `_showRepeaterDetails`. Port of `buildChartFromPoints` (`RepeaterStats.fromCoverageWithPoints`) + `drawRepeaterCoverageFromCache` (`repeaterCoverageCells`). -- **Volume cap**: both cap at the farthest 250 lines/cells (longest reach kept), - logged under `[COVERAGE]` when truncated. +- **Repeater Coverage Lines switch** (Settings > Map > Coverage Overlay, + `preferences.showRepeaterCoverageLines`, default off; the web's "Repeater + Coverage" layer `mm_std_repcov` defaults on): gates only Feature B's lines. + The cells, dim and framing always draw, in status colours (the web's + lines-off highlight blue is not copied). `_drawRepeaterCoverage` keeps the + lines in `_repeaterCoverageLines` (`RepeaterCoverageLines`, cleared with the + cells in `_clearCoverageCells` and on style reload), so a flip redraws or + clears the selected repeater's lines at once (web `mmRedrawSelectedCoverage` + parity). The setter does not bump `mapRevision`: MapWidget applies it from a + direct provider listener (`_onRepeaterCoverageLinesNotify`), which leaves the + shared lines layer alone while a tapped tile's fan-out (Feature A, not + covered by the switch) owns it (`repeaterCoverageLinesAfterSwitch`). +- **Volume cap**: the tile fan-out keeps the farthest 250 lines, a repeater the + farthest 5000 cells (longest reach kept), logged under `[COVERAGE]` when + truncated. - **Layers** (`map_widget.dart`): `coverage-lines-layer` (shared A/B, per-feature `color`) and `coverage-cells-layer` (per-feature fill) — install-once empty, updated via `setGeoJsonSource`, kept separate from the focus-mode lines so the diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 9b65ab3..7746632 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -162,6 +162,11 @@ class UserPreferences { /// applied server-side). Only these two values are valid. final int coverageGridSize; + /// Draw a dashed line from a selected repeater to each coverage square that + /// heard it. Off by default: near a busy repeater the lines hide the squares, + /// which already show the coverage. The web keeps them on (`mm_std_repcov`). + final bool showRepeaterCoverageLines; + /// Disconnect alert: play audible alert when pinging stops unexpectedly (BLE disconnect, idle timeout, maintenance) final bool disconnectAlertEnabled; @@ -227,6 +232,7 @@ class UserPreferences { this.mapTilesEnabled = true, this.coverageOverlayOpacity = 0.7, this.coverageGridSize = 300, + this.showRepeaterCoverageLines = false, this.disconnectAlertEnabled = false, this.customApiEnabled = false, this.customApiUrl, @@ -300,6 +306,8 @@ class UserPreferences { 100 => 100, _ => 300, }, + showRepeaterCoverageLines: + (json['showRepeaterCoverageLines'] as bool?) ?? false, disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, customApiEnabled: (json['customApiEnabled'] as bool?) ?? false, @@ -398,6 +406,7 @@ class UserPreferences { 'mapTilesEnabled': mapTilesEnabled, 'coverageOverlayOpacity': coverageOverlayOpacity, 'coverageGridSize': coverageGridSize, + 'showRepeaterCoverageLines': showRepeaterCoverageLines, 'disconnectAlertEnabled': disconnectAlertEnabled, 'customApiEnabled': customApiEnabled, 'customApiUrl': customApiUrl, @@ -456,6 +465,7 @@ class UserPreferences { bool? mapTilesEnabled, double? coverageOverlayOpacity, int? coverageGridSize, + bool? showRepeaterCoverageLines, bool? disconnectAlertEnabled, bool? customApiEnabled, String? customApiUrl, @@ -519,6 +529,8 @@ class UserPreferences { coverageOverlayOpacity: coverageOverlayOpacity ?? this.coverageOverlayOpacity, coverageGridSize: coverageGridSize ?? this.coverageGridSize, + showRepeaterCoverageLines: + showRepeaterCoverageLines ?? this.showRepeaterCoverageLines, disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, customApiEnabled: customApiEnabled ?? this.customApiEnabled, @@ -602,6 +614,7 @@ class UserPreferences { other.colorVisionType == colorVisionType && other.mapTilesEnabled == mapTilesEnabled && other.coverageOverlayOpacity == coverageOverlayOpacity && + other.showRepeaterCoverageLines == showRepeaterCoverageLines && other.disconnectAlertEnabled == disconnectAlertEnabled && other.customApiEnabled == customApiEnabled && other.customApiUrl == customApiUrl && @@ -655,6 +668,7 @@ class UserPreferences { colorVisionType, mapTilesEnabled, coverageOverlayOpacity, + showRepeaterCoverageLines, disconnectAlertEnabled, customApiEnabled, customApiUrl, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index ff03b4f..1e2c7a2 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -10208,6 +10208,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _savePreferences(); } + /// Show or hide the dashed lines from a selected repeater to the coverage + /// squares that heard it, and persist. Like the opacity above this must NOT + /// bump mapRevision: nothing in MapWidget.build() reads it. MapWidget redraws + /// or clears the lines of the selected repeater from its direct provider + /// listener (`_onRepeaterCoverageLinesNotify`), so the change shows straight + /// away, even while the Settings tab is in front of the map. + void setShowRepeaterCoverageLines(bool value) { + _preferences = _preferences.copyWith(showRepeaterCoverageLines: value); + debugLog('[MAP] Repeater coverage lines set to $value'); + notifyListeners(); + _savePreferences(); + } + /// Set app theme mode (dark/light) and persist void setThemeMode(String mode) { _preferences = _preferences.copyWith(themeMode: mode); diff --git a/lib/screens/settings/map_settings_page.dart b/lib/screens/settings/map_settings_page.dart index 75bac18..2ce5f78 100644 --- a/lib/screens/settings/map_settings_page.dart +++ b/lib/screens/settings/map_settings_page.dart @@ -98,6 +98,16 @@ class MapSettingsPage extends StatelessWidget { trailing: const Icon(Icons.chevron_right), onTap: () => _showColorVisionSelector(context, appState), ), + // Its own setter, not updatePreferences: the selected repeater's + // lines update in place, with no map rebuild. + SwitchListTile( + secondary: const Icon(Icons.hub), + title: const Text('Repeater Coverage Lines'), + subtitle: const Text( + 'Dashed lines from a selected repeater to the squares that heard it'), + value: prefs.showRepeaterCoverageLines, + onChanged: appState.setShowRepeaterCoverageLines, + ), ]), SettingsSectionCard(title: 'Markers', children: [ ListTile( diff --git a/lib/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart index 1f6ee6c..60b36b6 100644 --- a/lib/utils/coverage_summary.dart +++ b/lib/utils/coverage_summary.dart @@ -766,6 +766,48 @@ List repeaterCoverageCells( return cells.values.toList(); } +/// One dashed coverage line, from a shared start point out to ([lat],[lon]), +/// in [color] (a hex string). +typedef CoverageLineSegment = ({double lat, double lon, String color}); + +/// The coverage lines drawn for a selected repeater (Feature B): one per +/// coverage cell, from the repeater at ([lat],[lon]) to the cell centre. The +/// map keeps them while the repeater stays selected so the Repeater Coverage +/// Lines switch can redraw or clear them without a reselect (web +/// `mmRedrawSelectedCoverage` parity). +class RepeaterCoverageLines { + final String repeaterId; // full identity of the selected repeater + final double lat; + final double lon; + final List segments; + + const RepeaterCoverageLines({ + required this.repeaterId, + required this.lat, + required this.lon, + required this.segments, + }); + + /// The segments to draw with the switch at [show]. Empty clears the layer. + List visible(bool show) => show ? segments : const []; +} + +/// What flipping the Repeater Coverage Lines switch to [show] does to the +/// lines layer: the segments to push (empty clears it), or null to leave the +/// layer alone. Null when no repeater coverage is on the map ([drawn] null), +/// when it belongs to a repeater other than [selectedRepeaterId], or when a +/// tapped tile is open ([tileSelected]): the layer is shared with the tile +/// fan-out, which the switch does not cover. +List? repeaterCoverageLinesAfterSwitch( + RepeaterCoverageLines? drawn, + {required bool show, + required String? selectedRepeaterId, + required bool tileSelected}) { + if (drawn == null || tileSelected) return null; + if (drawn.repeaterId != selectedRepeaterId) return null; + return drawn.visible(show); +} + // --- cell fan-out endpoints (Feature A) -------------------------------------- /// One repeater endpoint that heard a ping in a tapped cell, ready to render as diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 9711d12..9f364a4 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -901,6 +901,13 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // _syncAllAnnotations) so the two features never wipe each other. bool _coverageLinesInstalled = false; bool _coverageCellsInstalled = false; + // The selected repeater's coverage lines, kept while its cells are on the + // map (cleared with them in _clearCoverageCells) so the Repeater Coverage + // Lines switch can redraw or clear them without a reselect. + RepeaterCoverageLines? _repeaterCoverageLines; + // The switch value _onRepeaterCoverageLinesNotify last saw, so it acts only + // on a flip and not on every provider notify. + bool? _lastSeenShowRepeaterCoverageLines; // Set while a tapped cell's fan-out is shown: the repeater ids that heard the // cell's pings. Non-null -> _buildRepeaterFeatureCollection hides every // repeater NOT in the set (web fades-but-keeps; the app hides for consistency @@ -1060,10 +1067,36 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // a direct listener too. See _onCoverageOpacityNotify. _patchProviderRef!.addListener(_onCoverageOpacityNotify); _patchProviderRef!.addListener(_onCoverageFilterNotify); + // The Repeater Coverage Lines switch does not bump mapRevision either. + _lastSeenShowRepeaterCoverageLines = + _patchProviderRef!.preferences.showRepeaterCoverageLines; + _patchProviderRef!.addListener(_onRepeaterCoverageLinesNotify); } AppStateProvider? _patchProviderRef; + /// Fires on every provider notify; applies a flipped Repeater Coverage Lines + /// switch to the selected repeater straight away (web + /// `mmRedrawSelectedCoverage` parity), no rebuild. Leaves the lines layer + /// alone unless the selected repeater's own lines are drawn: a tapped tile's + /// fan-out shares the layer and the switch does not cover it. + void _onRepeaterCoverageLinesNotify() { + final appState = _patchProviderRef; + if (appState == null || !mounted) return; + final show = appState.preferences.showRepeaterCoverageLines; + if (show == _lastSeenShowRepeaterCoverageLines) return; + _lastSeenShowRepeaterCoverageLines = show; + final drawn = _repeaterCoverageLines; + final segments = repeaterCoverageLinesAfterSwitch(drawn, + show: show, + selectedRepeaterId: _isolatedRepeaterId, + tileSelected: _cellPopupActive); + if (drawn == null || segments == null) return; + debugLog('[COVERAGE] repeater coverage lines ${show ? 'on' : 'off'}: ' + '${segments.length} drawn'); + _updateCoverageLines(segments, drawn.lat, drawn.lon, width: 1.5); + } + /// Auth/release can change the effective window on a UI-only notify. /// Observe it directly so the memoized map never keeps a regional override. void _onCoverageFilterNotify() { @@ -1194,6 +1227,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { _patchProviderRef?.removeListener(_onPositionNotify); _patchProviderRef?.removeListener(_onCoverageOpacityNotify); _patchProviderRef?.removeListener(_onCoverageFilterNotify); + _patchProviderRef?.removeListener(_onRepeaterCoverageLinesNotify); _tileLoadTimeoutTimer?.cancel(); if (controller != null) { controller.removeListener(_onCameraChanged); @@ -3374,6 +3408,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { // also gone with the style; reset so the next draw treats it as fresh. _coverageLinesInstalled = false; _coverageCellsInstalled = false; + _repeaterCoverageLines = null; _coverageHeardRepeaterIds = null; _coverageDistanceLabelSymbols.clear(); _registeredCoverageLabelImages.clear(); @@ -6103,7 +6138,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } /// Empty the coverage-cells source so it renders nothing (layer stays). + /// Also forgets the selected repeater's kept lines: with no cells under them + /// the Repeater Coverage Lines switch must not draw them back. Future _clearCoverageCells() async { + _repeaterCoverageLines = null; if (_mapController == null || !_coverageCellsInstalled) return; try { await _mapController! @@ -6238,9 +6276,12 @@ class _MapWidgetState extends State with WidgetsBindingObserver { /// Feature B draw: render [matched] (the points that heard [repeater]) as /// deduped status-coloured coverage cells plus a status-coloured dashed line - /// from the repeater to each cell centre. The lines layer is ensured BEFORE - /// the cells layer so the lines read on top. Returns the (capped) cells so the - /// caller can frame them with the repeater. + /// from the repeater to each cell centre. The lines only draw while the + /// Repeater Coverage Lines switch is on, but are kept either way so a flip + /// can redraw them (_onRepeaterCoverageLinesNotify). The lines layer is + /// ensured BEFORE the cells layer (switch on or off) so the lines read on + /// top. Returns the (capped) cells so the caller can frame them with the + /// repeater. Future> _drawRepeaterCoverage(Repeater repeater, List> matched, String cvd, int gridSize) async { final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128]; @@ -6261,16 +6302,25 @@ class _MapWidgetState extends State with WidgetsBindingObserver { } await _ensureCoverageLinesLayer(); await _updateCoverageCells(cells, cvd, steps[0], steps[1]); - final segments = [ - for (final c in cells) - ( - lat: c.centerLat, - lon: c.centerLon, - color: CoverageTilePalette.colorsForStatus(cvd, c.st)[0], - ), - ]; + final lines = RepeaterCoverageLines( + repeaterId: _repeaterIdentity(repeater), + lat: repeater.lat, + lon: repeater.lon, + segments: [ + for (final c in cells) + ( + lat: c.centerLat, + lon: c.centerLon, + color: CoverageTilePalette.colorsForStatus(cvd, c.st)[0], + ), + ], + ); + _repeaterCoverageLines = lines; + final show = + _patchProviderRef?.preferences.showRepeaterCoverageLines ?? false; // Skinnier than the tile fan-out — a repeater draws many lines at once. - await _updateCoverageLines(segments, repeater.lat, repeater.lon, + // Switch off: an empty list clears any lines already drawn. + await _updateCoverageLines(lines.visible(show), repeater.lat, repeater.lon, width: 1.5); // Dim the base coverage tiles so the repeater's coloured cells + lines pop // (web `drawRepeaterCoverageFromCache` tile-dim parity). Restored in diff --git a/test/models/user_preferences_repeater_coverage_lines_test.dart b/test/models/user_preferences_repeater_coverage_lines_test.dart new file mode 100644 index 0000000..004e01d --- /dev/null +++ b/test/models/user_preferences_repeater_coverage_lines_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/user_preferences.dart'; + +void main() { + test('repeater coverage lines are off by default', () { + const prefs = UserPreferences(); + expect(prefs.showRepeaterCoverageLines, isFalse); + expect(prefs.toJson()['showRepeaterCoverageLines'], false); + }); + + test('a missing or null value falls back to off', () { + expect(UserPreferences.fromJson({}).showRepeaterCoverageLines, isFalse); + expect( + UserPreferences.fromJson({'showRepeaterCoverageLines': null}) + .showRepeaterCoverageLines, + isFalse); + }); + + test('round trips through json', () { + const prefs = UserPreferences(showRepeaterCoverageLines: true); + final back = UserPreferences.fromJson(prefs.toJson()); + expect(back.showRepeaterCoverageLines, isTrue); + expect(back, prefs); + }); + + test('copyWith changes only the field it is given', () { + const prefs = + UserPreferences(showRepeaterCoverageLines: true, mapStyle: 'dark'); + + final off = prefs.copyWith(showRepeaterCoverageLines: false); + expect(off.showRepeaterCoverageLines, isFalse); + expect(off.mapStyle, 'dark'); + + final style = prefs.copyWith(mapStyle: 'liberty'); + expect(style.showRepeaterCoverageLines, isTrue); + + expect(prefs.copyWith(), prefs); + }); + + test('equality and hashCode follow the field', () { + const on = UserPreferences(showRepeaterCoverageLines: true); + const alsoOn = UserPreferences(showRepeaterCoverageLines: true); + const off = UserPreferences(); + expect(on, alsoOn); + expect(on.hashCode, alsoOn.hashCode); + expect(on, isNot(off)); + expect(on.hashCode, isNot(off.hashCode)); + }); +} diff --git a/test/utils/repeater_coverage_lines_test.dart b/test/utils/repeater_coverage_lines_test.dart new file mode 100644 index 0000000..258cd50 --- /dev/null +++ b/test/utils/repeater_coverage_lines_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/coverage_summary.dart'; + +void main() { + const segments = [ + (lat: 45.01, lon: -75.01, color: '#00ff00'), + (lat: 45.02, lon: -75.02, color: '#ffff00'), + ]; + const drawn = RepeaterCoverageLines( + repeaterId: 'a1b2c3d4', + lat: 45.0, + lon: -75.0, + segments: segments, + ); + + group('RepeaterCoverageLines.visible', () { + test('switch on draws every line', () { + expect(drawn.visible(true), segments); + }); + + test('switch off draws none, which clears the layer', () { + expect(drawn.visible(false), isEmpty); + }); + }); + + group('repeaterCoverageLinesAfterSwitch', () { + test('on redraws the selected repeater\'s lines', () { + expect( + repeaterCoverageLinesAfterSwitch(drawn, + show: true, selectedRepeaterId: 'a1b2c3d4', tileSelected: false), + segments); + }); + + test('off clears the selected repeater\'s lines', () { + expect( + repeaterCoverageLinesAfterSwitch(drawn, + show: false, selectedRepeaterId: 'a1b2c3d4', tileSelected: false), + isEmpty); + }); + + test('no repeater coverage on the map leaves the layer alone', () { + for (final show in [true, false]) { + expect( + repeaterCoverageLinesAfterSwitch(null, + show: show, + selectedRepeaterId: 'a1b2c3d4', + tileSelected: false), + isNull); + } + }); + + test('a tapped tile owns the layer, so its fan-out is left alone', () { + for (final show in [true, false]) { + expect( + repeaterCoverageLinesAfterSwitch(drawn, + show: show, selectedRepeaterId: 'a1b2c3d4', tileSelected: true), + isNull); + } + }); + + test('lines kept for another repeater are never drawn back', () { + for (final selected in ['ffff0000', null]) { + expect( + repeaterCoverageLinesAfterSwitch(drawn, + show: true, selectedRepeaterId: selected, tileSelected: false), + isNull); + } + }); + }); +}