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 @@ -242,6 +242,15 @@ catalogue, which keep server names. A change goes through
`_siriRepeaterCatalogRevision`, call `_notifyMapNow()` (the overlay rebuilds on
`mapRevision`) and schedule the Live Activity, watch and Siri sync.

The "Repeater Names in Top Heard" setting (`UserPreferences.topHeardShowNames`,
Settings > Map, off by default and phone-local like the nicknames) lets a Top
Heard row with no nickname show the repeater's server name in place of the hex.
`RepeaterNicknames.labelFor(useServerName:)` cuts the name to
`RepeaterNicknames.maxLength` (8) grapheme clusters plus "…". A name of
'Unknown' or a hidden 🚫 name keeps the hex. It uses the same unique-match rule
as a nickname, and a nickname always wins. It affects only the in-app Top Heard
box; the Live Activity and watch have their own name rules above.

On the map, the minimized repeater pill shows the nickname as a small tag after
the name. The expanded repeater sheet has a Nickname button beside Manage, the
same height and shape, reading "Nickname" until one is set and then the
Expand Down Expand Up @@ -2338,10 +2347,14 @@ future breaking change makes undecodable still reaches the "update the iPhone
app" prompt instead of going silently stale.

**One heard list.** The map's Top Heard box (`_topRepeatersOverlay`, the
latest ping's top three by SNR, plus the passive RX slot) is the single source
latest ping's rows by SNR, plus the passive RX slot) is the single source
for the watch's heard rows (`ExternalSurfaceGeoBuilder.buildHeard`) and the
Live Activity's rows (`buildLiveActivityHeard` in
`lib/services/live_activity/live_activity_heard.dart`). It is replaced only by a
`lib/services/live_activity/live_activity_heard.dart`). The stored list holds up
to `UserPreferences.topHeardRowsMax` (8) rows. The in-app box shows
`topHeardRows` of them (3 to 8, Settings > Map > Top Heard Rows), and the watch
and Live Activity take the first three (`_surfaceTopHeardRows`), which keeps the
watch's `maxHeard` cap from dropping the RX row. It is replaced only by a
ping that heard something (direct TX echoes, discovery nodes, a successful trace
at its window's close), so a silent ping leaves the last heard set in place on
all three; the Live Activity marks rows from before the latest send as last
Expand Down
2 changes: 1 addition & 1 deletion docs/LIVE_ACTIVITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ MeshMapper starts one read-only Live Activity while an automatic wardriving sess

- Current mode and phase (`Sending`, `Listening`, `Next ping`, `Cooldown`, GPS/zone/reconnect states)
- System-rendered countdowns based on absolute timer deadlines
- The map's Top Heard rows: the latest ping's top three repeaters by SNR plus the current passive RX slot, the same list the watch shows. Rows heard before the latest send stay on the card dimmed as last heard; a silent ping does not wipe them. Repeater names (when "Repeater Names on Live Activity" is on) show the user's own nickname when set in Settings > Map > Repeater Nicknames, otherwise the server name
- The map's Top Heard rows: the latest ping's top three repeaters by SNR plus the current passive RX slot, the same list the watch shows. It stays three whatever the in-app Top Heard Rows setting is. Rows heard before the latest send stay on the card dimmed as last heard; a silent ping does not wipe them. Repeater names (when "Repeater Names on Live Activity" is on) show the user's own nickname when set in Settings > Map > Repeater Nicknames, otherwise the server name
- TX/RX counters, upload queue, zone, connection state, and stale-update warnings
- Lock Screen, Dynamic Island, and one iOS 18 small-family layout shared by the paired watch's Smart Stack card and the CarPlay dashboard. CarPlay's canvas overlaps the watch card sizes and nothing names the surface, so the card solves its row count and font from the measured height instead of guessing

Expand Down
40 changes: 38 additions & 2 deletions lib/models/user_preferences.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,26 @@ class UserPreferences {
/// Auto-stop auto-ping after 30 minutes of idle (no movement)
final bool autoStopAfterIdle;

/// Show top 3 repeaters by SNR on the map during wardriving
/// Show the Top Heard repeaters overlay (best SNR first) on the map during
/// wardriving
final bool showTopRepeaters;

/// Double the size of the Top 3 repeaters overlay for easier reading
/// Double the size of the Top Heard overlay for easier reading
final bool largerTopRepeaters;

/// Fewest and most rows the Top Heard overlay can show, and the default.
static const int topHeardRowsMin = 3;
static const int topHeardRowsMax = 8;
static const int topHeardRowsDefault = 3;

/// Rows the in-app Top Heard overlay shows (the RX row is extra). The Live
/// Activity, CarPlay and watch always show 3.
final int topHeardRows;

/// Top Heard rows with no nickname show the repeater's server name instead
/// of its hex ID.
final bool topHeardShowNames;

/// Live Activity heard rows show resolved repeater names (fewer, named
/// rows). When false the card packs a two-column hex grid instead.
final bool liveActivityShowNames;
Expand Down Expand Up @@ -204,6 +218,8 @@ class UserPreferences {
this.autoStopAfterIdle = true,
this.showTopRepeaters = false,
this.largerTopRepeaters = false,
this.topHeardRows = topHeardRowsDefault,
this.topHeardShowNames = false,
this.liveActivityShowNames = true,
this.markerStyle = 'dot',
this.gpsMarkerStyle = 'arrow',
Expand Down Expand Up @@ -271,6 +287,8 @@ class UserPreferences {
autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true,
showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false,
largerTopRepeaters: (json['largerTopRepeaters'] as bool?) ?? false,
topHeardRows: _topHeardRowsFromJson(json['topHeardRows']),
topHeardShowNames: (json['topHeardShowNames'] as bool?) ?? false,
liveActivityShowNames: (json['liveActivityShowNames'] as bool?) ?? true,
markerStyle: (json['markerStyle'] as String?) ?? 'dot',
gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?),
Expand Down Expand Up @@ -323,6 +341,14 @@ class UserPreferences {
return value;
}

/// A stored Top Heard row count, clamped to the supported range. A missing
/// or non-numeric value (or NaN/infinity) gives [topHeardRowsDefault], so a
/// bad stored value never reaches the UI.
static int _topHeardRowsFromJson(Object? value) {
if (value is! num || !value.isFinite) return topHeardRowsDefault;
return value.toInt().clamp(topHeardRowsMin, topHeardRowsMax);
}

/// Convert to JSON (for persistence)
Map<String, dynamic> toJson() {
return {
Expand Down Expand Up @@ -363,6 +389,8 @@ class UserPreferences {
'autoStopAfterIdle': autoStopAfterIdle,
'showTopRepeaters': showTopRepeaters,
'largerTopRepeaters': largerTopRepeaters,
'topHeardRows': topHeardRows,
'topHeardShowNames': topHeardShowNames,
'liveActivityShowNames': liveActivityShowNames,
'markerStyle': markerStyle,
'gpsMarkerStyle': gpsMarkerStyle,
Expand Down Expand Up @@ -419,6 +447,8 @@ class UserPreferences {
bool? autoStopAfterIdle,
bool? showTopRepeaters,
bool? largerTopRepeaters,
int? topHeardRows,
bool? topHeardShowNames,
bool? liveActivityShowNames,
String? markerStyle,
String? gpsMarkerStyle,
Expand Down Expand Up @@ -478,6 +508,8 @@ class UserPreferences {
autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle,
showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters,
largerTopRepeaters: largerTopRepeaters ?? this.largerTopRepeaters,
topHeardRows: topHeardRows ?? this.topHeardRows,
topHeardShowNames: topHeardShowNames ?? this.topHeardShowNames,
liveActivityShowNames:
liveActivityShowNames ?? this.liveActivityShowNames,
markerStyle: markerStyle ?? this.markerStyle,
Expand Down Expand Up @@ -562,6 +594,8 @@ class UserPreferences {
other.minPingDistanceMeters == minPingDistanceMeters &&
other.autoStopAfterIdle == autoStopAfterIdle &&
other.showTopRepeaters == showTopRepeaters &&
other.topHeardRows == topHeardRows &&
other.topHeardShowNames == topHeardShowNames &&
other.liveActivityShowNames == liveActivityShowNames &&
other.markerStyle == markerStyle &&
other.gpsMarkerStyle == gpsMarkerStyle &&
Expand Down Expand Up @@ -613,6 +647,8 @@ class UserPreferences {
minPingDistanceMeters,
autoStopAfterIdle,
showTopRepeaters,
topHeardRows,
topHeardShowNames,
liveActivityShowNames,
markerStyle,
gpsMarkerStyle,
Expand Down
60 changes: 38 additions & 22 deletions lib/providers/app_state_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -471,14 +471,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {

// Top repeaters overlay, updated live on each ping event. The map's Top
// Heard box, the watch's heard rows and the Live Activity's rows all read
// this one list, so the three surfaces cannot disagree.
// this one list, so the three surfaces cannot disagree. It holds up to
// UserPreferences.topHeardRowsMax rows; the map shows the user's row
// setting of them and the other surfaces the first [_surfaceTopHeardRows].
List<({String repeaterId, double snr, OverlayPingType type})>
_topRepeatersOverlay = [];
DateTime? _topRepeatersOverlayUpdatedAt;

/// How many repeaters the latest ping heard in all, beyond the three the
/// overlay keeps. The Live Activity's "HEARD NOW n" header counts them.
int _topRepeatersOverlayTotalCount = 0;
/// Ping rows the Live Activity, CarPlay and the watch show, whatever the
/// in-app Top Heard Rows setting is.
static const int _surfaceTopHeardRows = 3;

/// Every repeater the latest ping heard (upper-case display IDs), regardless
/// of how many rows are stored or shown. The Live Activity's "HEARD NOW n"
/// header counts them, plus the RX repeater only when it is not one of them.
Set<String> _topRepeatersOverlayHeardIds = const {};

/// The fullest identity known for each overlay row, keyed by the display hash
/// the row is shown under.
Expand Down Expand Up @@ -883,7 +890,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
RepeaterNicknames.nicknameFor(repeater, _repeaterNicknames);

/// The text for a Top Heard row: its nickname when the row resolves to one
/// repeater that has one, else [repeaterId] unchanged.
/// repeater that has one, else that repeater's server name when the Top
/// Heard names setting is on, else [repeaterId] unchanged.
String topHeardLabel(String repeaterId) {
final id = repeaterId.toUpperCase();
return RepeaterNicknames.labelFor(
Expand All @@ -892,6 +900,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
repeaters: _repeaters,
hopBytes: _hopBytes,
nicknames: _repeaterNicknames,
useServerName: _preferences.topHeardShowNames,
) ??
repeaterId;
}
Expand Down Expand Up @@ -1377,16 +1386,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
List<TxPing> get txPings => List.unmodifiable(_txPings);
List<RxPing> get rxPings => List.unmodifiable(_rxPings);

/// Top 3 repeaters by best SNR from TX/DISC/Trace pings
/// The in-app Top Heard rows: the latest ping's repeaters by best SNR from
/// TX/DISC/Trace pings, up to the user's Top Heard Rows setting.
List<({String repeaterId, double snr, OverlayPingType type})>
get topRepeatersBySnr => _topRepeatersOverlay;
get topRepeatersBySnr =>
_topRepeatersOverlay.take(_preferences.topHeardRows).toList();

/// Best RX observation in the current 5-second window
({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot;

/// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping.
/// Replaces all 3 slots entirely (no carryover from previous pings). Only a
/// ping that heard something calls this, so a silent ping leaves the last
/// Replaces every stored row entirely (no carryover from previous pings),
/// keeping up to [UserPreferences.topHeardRowsMax] so a higher Top Heard Rows
/// setting takes effect on the next rebuild without waiting for a ping. Only
/// a ping that heard something calls this, so a silent ping leaves the last
/// heard set in place on the map, the watch and the Live Activity alike.
/// - Parameter identities: display hash to the fullest identity this ping
/// carried for it, for the ping types that carry more than a path byte.
Expand All @@ -1405,8 +1418,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
.map((e) => (repeaterId: e.key, snr: e.value, type: type))
.toList()
..sort((a, b) => b.snr.compareTo(a.snr));
_topRepeatersOverlay = fresh.take(3).toList();
_topRepeatersOverlayTotalCount = fresh.length;
_topRepeatersOverlay = fresh.take(UserPreferences.topHeardRowsMax).toList();
_topRepeatersOverlayHeardIds = bestSnr.keys.toSet();
_topRepeatersOverlayUpdatedAt = DateTime.now();
}

Expand Down Expand Up @@ -1466,10 +1479,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
}
}

/// Clear all overlay state (top 3 + RX slot).
/// Clear all overlay state (top heard rows + RX slot).
void _clearOverlayState() {
_topRepeatersOverlay = [];
_topRepeatersOverlayTotalCount = 0;
_topRepeatersOverlayHeardIds = const {};
_topRepeatersOverlayUpdatedAt = null;
_overlayIdentityById = const {};
_rxOverlaySlot = null;
Expand Down Expand Up @@ -2374,10 +2387,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
final position = _resolveWatchPosition();
final ranking = _resolveRankingPosition();

// The wrist mirrors the map's "Top Heard" overlay: the latest ping's top
// three by SNR plus the current RX slot. Same source, so the two surfaces
// can never disagree.
final top = _topRepeatersOverlay;
// The wrist mirrors the map's "Top Heard" overlay: the top three of the
// same stored list, plus the current RX slot. Same source, so the two
// surfaces can never disagree. The list is cut to three here by design: the
// Live Activity, CarPlay and watch show 3 ping rows plus RX, whatever the
// in-app Top Heard Rows setting is. buildHeard's RX cap is the backstop.
final top = _topRepeatersOverlay.take(_surfaceTopHeardRows).toList();
final rxSlot = _rxOverlaySlot;

// Overlay IDs are hex path hashes. Resolved from the fullest identity each
Expand Down Expand Up @@ -3532,16 +3547,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
? (endsAt: t.endTime!, durationMs: null, remainingSec: t.remainingSec)
: null;

/// The Live Activity's rows are the map's Top Heard box, the same list the
/// watch mirrors, so the three surfaces cannot disagree. The ordering, the
/// RX slot and the current-or-last rule live in [buildLiveActivityHeard].
/// The Live Activity's rows are the first three of the map's Top Heard list,
/// the same three the watch mirrors, so the surfaces cannot disagree. The
/// ordering, the RX slot and the current-or-last rule live in
/// [buildLiveActivityHeard].
({
List<LiveActivityRepeater> repeaters,
int totalCount,
bool isCurrent,
}) _buildLiveActivityRepeaters() => buildLiveActivityHeard(
top: _topRepeatersOverlay,
topTotalCount: _topRepeatersOverlayTotalCount,
top: _topRepeatersOverlay.take(_surfaceTopHeardRows).toList(),
topHeardIds: _topRepeatersOverlayHeardIds,
rxSlot: _rxOverlaySlot,
topAt: _topRepeatersOverlayUpdatedAt,
rxAt: _liveActivityRxUpdatedAt,
Expand Down
78 changes: 73 additions & 5 deletions lib/screens/settings/map_settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';

import '../../models/user_preferences.dart';
import '../../providers/app_state_provider.dart';
import '../offline_maps_screen.dart';
import 'repeater_nicknames_page.dart';
Expand Down Expand Up @@ -115,19 +116,35 @@ class MapSettingsPage extends StatelessWidget {
),
SwitchListTile(
secondary: const Icon(Icons.cell_tower),
title: const Text('Top Repeaters on Map'),
subtitle:
const Text('Show top 3 repeaters by SNR from last ping'),
title: const Text('Top Heard on Map'),
subtitle: const Text('Strongest repeaters from the last ping'),
value: prefs.showTopRepeaters,
onChanged: (value) {
appState
.updatePreferences(prefs.copyWith(showTopRepeaters: value));
},
),
if (prefs.showTopRepeaters)
if (prefs.showTopRepeaters) ...[
ListTile(
leading: const Icon(Icons.format_list_numbered),
title: const Text('Top Heard Rows'),
subtitle: Text('${prefs.topHeardRows} rows'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showTopHeardRowsSelector(context, appState),
),
SwitchListTile(
secondary: const Icon(Icons.badge_outlined),
title: const Text('Repeater Names in Top Heard'),
subtitle: const Text('Names instead of hex IDs'),
value: prefs.topHeardShowNames,
onChanged: (value) {
appState.updatePreferences(
prefs.copyWith(topHeardShowNames: value));
},
),
SwitchListTile(
secondary: const Icon(Icons.text_increase),
title: const Text('Larger Top 3 Overlay'),
title: const Text('Larger Top Heard Overlay'),
subtitle: const Text(
'Increase the size of the overlay for easier reading'),
value: prefs.largerTopRepeaters,
Expand All @@ -136,6 +153,7 @@ class MapSettingsPage extends StatelessWidget {
prefs.copyWith(largerTopRepeaters: value));
},
),
],
ListTile(
leading: const Icon(Icons.label_outline),
title: const Text('Repeater Nicknames'),
Expand Down Expand Up @@ -314,6 +332,56 @@ class MapSettingsPage extends StatelessWidget {
};
}

/// How many ping rows the in-app Top Heard box shows. The RX row is extra,
/// and the Live Activity, CarPlay and watch keep 3 ping rows.
void _showTopHeardRowsSelector(
BuildContext context, AppStateProvider appState) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('Top Heard Rows',
style:
TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
RadioGroup<int>(
groupValue: appState.preferences.topHeardRows,
onChanged: (v) {
if (v != null) {
appState.updatePreferences(
appState.preferences.copyWith(topHeardRows: v));
}
Navigator.pop(context);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var n = UserPreferences.topHeardRowsMin;
n <= UserPreferences.topHeardRowsMax;
n++)
RadioListTile<int>(
title: Text('$n rows'),
value: n,
),
],
),
),
const SizedBox(height: 8),
],
),
),
),
);
}

/// Grid Mode preset selector — mirrors the web UI's Grid Mode
/// (Simplified = 300 m, Detailed = 100 m + blob, applied server-side).
void _showCoverageGridSelector(
Expand Down
Loading
Loading