Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions lib/models/user_preferences.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -398,6 +406,7 @@ class UserPreferences {
'mapTilesEnabled': mapTilesEnabled,
'coverageOverlayOpacity': coverageOverlayOpacity,
'coverageGridSize': coverageGridSize,
'showRepeaterCoverageLines': showRepeaterCoverageLines,
'disconnectAlertEnabled': disconnectAlertEnabled,
'customApiEnabled': customApiEnabled,
'customApiUrl': customApiUrl,
Expand Down Expand Up @@ -456,6 +465,7 @@ class UserPreferences {
bool? mapTilesEnabled,
double? coverageOverlayOpacity,
int? coverageGridSize,
bool? showRepeaterCoverageLines,
bool? disconnectAlertEnabled,
bool? customApiEnabled,
String? customApiUrl,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -655,6 +668,7 @@ class UserPreferences {
colorVisionType,
mapTilesEnabled,
coverageOverlayOpacity,
showRepeaterCoverageLines,
disconnectAlertEnabled,
customApiEnabled,
customApiUrl,
Expand Down
13 changes: 13 additions & 0 deletions lib/providers/app_state_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions lib/screens/settings/map_settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
42 changes: 42 additions & 0 deletions lib/utils/coverage_summary.dart
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,48 @@ List<RepeaterCoverageCell> 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<CoverageLineSegment> 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<CoverageLineSegment> 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<CoverageLineSegment>? 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
Expand Down
74 changes: 62 additions & 12 deletions lib/widgets/map_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,13 @@ class _MapWidgetState extends State<MapWidget> 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
Expand Down Expand Up @@ -1060,10 +1067,36 @@ class _MapWidgetState extends State<MapWidget> 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() {
Expand Down Expand Up @@ -1194,6 +1227,7 @@ class _MapWidgetState extends State<MapWidget> with WidgetsBindingObserver {
_patchProviderRef?.removeListener(_onPositionNotify);
_patchProviderRef?.removeListener(_onCoverageOpacityNotify);
_patchProviderRef?.removeListener(_onCoverageFilterNotify);
_patchProviderRef?.removeListener(_onRepeaterCoverageLinesNotify);
_tileLoadTimeoutTimer?.cancel();
if (controller != null) {
controller.removeListener(_onCameraChanged);
Expand Down Expand Up @@ -3374,6 +3408,7 @@ class _MapWidgetState extends State<MapWidget> 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();
Expand Down Expand Up @@ -6103,7 +6138,10 @@ class _MapWidgetState extends State<MapWidget> 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<void> _clearCoverageCells() async {
_repeaterCoverageLines = null;
if (_mapController == null || !_coverageCellsInstalled) return;
try {
await _mapController!
Expand Down Expand Up @@ -6238,9 +6276,12 @@ class _MapWidgetState extends State<MapWidget> 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<List<RepeaterCoverageCell>> _drawRepeaterCoverage(Repeater repeater,
List<Map<String, dynamic>> matched, String cvd, int gridSize) async {
final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128];
Expand All @@ -6261,16 +6302,25 @@ class _MapWidgetState extends State<MapWidget> 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
Expand Down
49 changes: 49 additions & 0 deletions test/models/user_preferences_repeater_coverage_lines_test.dart
Original file line number Diff line number Diff line change
@@ -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));
});
}
Loading
Loading