diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0f01004f..c2386246 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -194,6 +194,70 @@ back to `hop_bytes`, independently of regional TX path-width enforcement. Coverage requests use up to 40 characters of the full key to meet the API's prefix limit. Legacy narrow coverage tokens can remain inherently ambiguous. +### Repeater Nicknames + +A user can give a repeater a short personal nickname ("Mell" for the repeater +whose key starts 5007) that is shown where a heard repeater is named. It stays +on the phone: no account sync and no server involvement. Nicknames are kept in +the Hive `user_preferences` box under `repeater_nicknames`, a map of full +64-hex public key to `{nickname, name, hex}`, and read back through +`RepeaterNicknames.sanitize` (`lib/utils/repeater_nicknames.dart`), which drops +a bad entry instead of throwing. `name` and `hex` are snapshots of the server +name and display hex taken at save time, so the Settings list can describe a +repeater outside the current zone's catalogue; they are display-only and never +used to match. A nickname is capped at 8 characters +(`RepeaterNicknames.maxLength`), and only a repeater with a full public key can +take one. + +Nicknames are global to the phone and never scoped to a zone or IATA, so they +survive leaving a region and coming back. Nothing clears them on a zone change, +catalogue reload, disconnect, log clear or session end, and resolving against +whatever catalogue is loaded brings one back when its repeater is in view +again. The only thing that drops them is the existing corrupt-box recovery +(`_openBoxSafely`) that deletes the whole `user_preferences` box, like every +other preference. + +Matching is by full key. A heard ID shows a nickname only when it resolves to +exactly one catalogue repeater, trying the fullest identity the row arrived +with first and the display hash second; an ambiguous ID keeps its hex, because +a confidently wrong name is worse than none. Top Heard +(`AppStateProvider.topHeardLabel`, via `RepeaterNicknames.labelFor`), the Live +Activity and Siri all resolve through `ExternalSurfaceGeoBuilder.resolveOverlayRow`; +the watch's heard rows use `resolveOverlayRepeaters`, which applies the same +identity-first, unique-only rule to the whole set of rows at once. + +The nickname replaces the hex on the Top Heard overlay. On the Live Activity and +CarPlay heard rows (`_resolveRepeaterDisplayName`) it wins over the server name +in the existing name slot beside the hex, still behind "Repeater Names on Live +Activity". It also appears on the Apple Watch heard rows +(`buildHeard(nicknames:)`) and in Siri's heard observations +(`buildRecentHeard(nicknames:)`). `_resolveRepeaterDisplayName` also feeds +`_targetRepeaterDisplayName`, so in Trace mode the nickname names the target in +the session phase text on the Live Activity and watch ("Tracing ", +"Waiting for to reply", "To "). That text was never behind the +"Repeater Names on Live Activity" toggle; only the heard rows are. It is not +used in log chips, ping popups, map markers or the Siri/Shortcuts repeater +catalogue, which keep server names. A change goes through +`setRepeaterNickname` or `setRepeaterNicknameByKey`, which bump +`_siriRepeaterCatalogRevision`, call `_notifyMapNow()` (the overlay rebuilds on +`mapRevision`) and schedule the Live Activity, watch and Siri sync. + +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 +nickname itself, for repeaters with a full public key only (without one, Manage +keeps the full width). The tag, the button and the hex badge are the shared +`RepeaterNicknameTag`, `RepeaterNicknameButton` and `RepeaterHexBadge` in +`lib/widgets/repeater_nickname_widgets.dart`. The +other entry point is Settings > Map > Repeater Nicknames +(`RepeaterNicknameList` in `lib/screens/settings/repeater_nicknames_page.dart`): +list, add through the repeater picker (which offers only repeaters with a full +key), edit and remove. A removal offers Undo, and removals made while that bar +is up share it (`NicknameRemovalBatch`), so one Undo brings them all back. The +sheet and the list's add share one ask-then-save flow, `editRepeaterNickname` +in `lib/widgets/repeater_nickname_dialog.dart`, and nicknames made only of +invisible characters are treated as blank. + ### Service-Oriented Architecture The app uses a layered service architecture with clear separation of concerns: @@ -2294,6 +2358,8 @@ carries the responder's full 64-character public key, and a trace carries its `_overlayIdentityById`, **replaced wholesale per ping and never merged**, because a hash that meant one repeater in a discovery response says nothing about who a later TX echo under the same hash was. +A user's own nickname for the resolved repeater wins over the server name; see +"Repeater Nicknames". TX echoes and passive RX carry only the path byte, so they fall back to prefix matching and keep refusing to guess. That fallback indexes per *distinct* @@ -2977,5 +3043,9 @@ All API endpoints may return maintenance mode: - `lib/utils/repeater_marker_style.dart` - Repeater marker geometry, the five-state colour registry, and the cluster dominant/presence expressions - `lib/utils/cluster_spread.dart` - The zoom that pulls a repeater cluster apart, deciding zoom-vs-spread on tap - `lib/utils/repeater_marker_painter.dart` - Draws the repeater chip, the cluster badge disc and its presence dots +- `lib/utils/repeater_nicknames.dart` - Personal repeater nicknames: `RepeaterNicknames.sanitize`, the 8-character cap, and resolution of a heard row to its nickname +- `lib/widgets/repeater_nickname_dialog.dart` - The dialog that sets or removes a nickname, shared by the map sheet and Settings +- `lib/widgets/repeater_nickname_widgets.dart` - `RepeaterHexBadge`, `RepeaterNicknameTag` and `RepeaterNicknameButton`, the pieces the nickname UI is drawn from +- `lib/screens/settings/repeater_nicknames_page.dart` - Settings > Map > Repeater Nicknames: the list with add, edit, remove and Undo - `lib/models/noise_floor_session.dart` - Noise floor session data models - `lib/widgets/noise_floor_chart.dart` - Noise floor graph visualization diff --git a/docs/LIVE_ACTIVITIES.md b/docs/LIVE_ACTIVITIES.md index 0c928fd3..a8812429 100644 --- a/docs/LIVE_ACTIVITIES.md +++ b/docs/LIVE_ACTIVITIES.md @@ -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 +- 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 - 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 diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 25d5e9f6..0f3c42f0 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -99,7 +99,9 @@ import '../services/repeater_admin/repeater_admin_module.dart'; import '../services/repeater_admin/repeater_admin_session.dart'; import '../utils/constants.dart'; import '../utils/geo_validation.dart'; +import '../utils/public_key.dart'; import '../utils/repeater_collision.dart'; +import '../utils/repeater_nicknames.dart'; import '../utils/ping_colors.dart'; import '../utils/radio_filter.dart' as radio_filter; import '../utils/zone_group.dart'; @@ -844,6 +846,132 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return null; } + // ============================================ + // Repeater nicknames (personal labels, kept on the phone) + // ============================================ + + /// Box key for the saved nicknames, an untyped map in the preferences box. + static const String _repeaterNicknamesKey = 'repeater_nicknames'; + + /// The user's nicknames, keyed by the repeater's full public key. + /// + /// Global to the phone and never scoped to a zone or IATA: a nickname must + /// survive leaving a region and coming back. Nothing clears or filters this + /// on a zone change, a catalogue reload, a disconnect, a log clear or a + /// session end. Only [setRepeaterNickname] and [setRepeaterNicknameByKey] + /// change it, and resolution against whatever catalogue is loaded brings a + /// nickname back when its repeater is in view again. + Map _repeaterNicknames = const {}; + + /// Every saved nickname as (full public key, entry), sorted by nickname + /// without regard to case, then by key so two repeaters given the same + /// nickname keep their order. Unmodifiable. + List> get repeaterNicknames => + List.unmodifiable(_repeaterNicknames.entries.toList() + ..sort((a, b) { + final byName = a.value.nickname + .toLowerCase() + .compareTo(b.value.nickname.toLowerCase()); + return byName != 0 ? byName : a.key.compareTo(b.key); + })); + + /// How many nicknames are saved. + int get repeaterNicknameCount => _repeaterNicknames.length; + + /// The nickname saved for [repeater], or null when it has none. + String? nicknameForRepeater(Repeater repeater) => + 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. + String topHeardLabel(String repeaterId) { + final id = repeaterId.toUpperCase(); + return RepeaterNicknames.labelFor( + displayId: id, + identity: _overlayIdentityById[id], + repeaters: _repeaters, + hopBytes: _hopBytes, + nicknames: _repeaterNicknames, + ) ?? + repeaterId; + } + + /// Give [repeater] the nickname [nickname], or remove its nickname when + /// [nickname] is null or blank. Does nothing for a repeater without a full + /// public key, which the UI never offers. + Future setRepeaterNickname(Repeater repeater, String? nickname) async { + final key = normalizePublicKey(repeater.hexId); + if (key == null) { + debugWarn('[SETTINGS] Repeater nickname ignored: no full public key'); + return; + } + final n = RepeaterNicknames.normalizeNickname(nickname); + await _onRepeaterNicknamesChanged( + key, + set: n == null + ? null + : RepeaterNickname( + nickname: n, + name: repeater.name, + hex: repeater.displayHexId(), + ), + ); + } + + /// Like [setRepeaterNickname] for a repeater known only by [publicKey], as + /// in the Settings list, whose entries may not be in the current catalogue. + /// + /// [name] and [hex] describe the repeater in the list: the arguments when + /// given (Undo re-adds a removed entry this way), else the existing entry's. + Future setRepeaterNicknameByKey( + String publicKey, + String? nickname, { + String? name, + String? hex, + }) async { + final key = normalizePublicKey(publicKey); + if (key == null) { + debugWarn('[SETTINGS] Repeater nickname ignored: not a full public key'); + return; + } + final n = RepeaterNicknames.normalizeNickname(nickname); + final existing = _repeaterNicknames[key]; + await _onRepeaterNicknamesChanged( + key, + set: n == null + ? null + : RepeaterNickname( + nickname: n, + name: name ?? existing?.name ?? '', + hex: hex ?? existing?.hex ?? key.substring(0, 4), + ), + ); + } + + /// Apply one change to the nicknames: [set] is the new entry for [key], or + /// null to remove it. Replaces the map rather than editing it, since the + /// getters hand out views of it. + Future _onRepeaterNicknamesChanged( + String key, { + required RepeaterNickname? set, + }) async { + final next = Map.of(_repeaterNicknames); + if (set == null) { + next.remove(key); + } else { + next[key] = set; + } + _repeaterNicknames = Map.unmodifiable(next); + // Part of the Siri preflight key, so the snapshot rebuilds. + _siriRepeaterCatalogRevision++; + // The Top Heard overlay only rebuilds on the map revision. + _notifyMapNow(); + _scheduleLiveActivitySync(); + await _saveRepeaterNicknames(); + debugLog('[SETTINGS] Repeater nickname ' + '${set != null ? 'set' : 'cleared'} for ${_pkPrefix(key)}'); + } + // ============================================ // Radio preset (multiple radio configurations per region) // ============================================ @@ -2278,6 +2406,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { rxAt: _liveActivityRxUpdatedAt, lat: ranking?.lat, lon: ranking?.lon, + nicknames: _repeaterNicknames, ); // The readout still needs the fix and Top Heard, but none of the arrays @@ -2567,6 +2696,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { traceEntries: _traceLogEntries, repeaters: _repeaters, hopBytes: _hopBytes, + nicknames: _repeaterNicknames, ); final phase = _resolveWatchPhase(); final passive = sessionStartAvailability(AutoMode.passive); @@ -3432,31 +3562,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _resolveRepeaterDisplayName(String rawId) { final displayId = rawId.toUpperCase(); - - // Try the fullest identity the row arrived with, then the display hash. - // - // **The fallback is the point.** A discovery response's public key is a - // better identity, but the catalogue is matched by an 8-character `hexId` - // that the API frequently omits, and the display hash additionally resolves - // through `displayHexId` — which falls back to the short numeric ID when - // there is no hex at all. Trying only the longer form threw away the lookup - // that was doing the work, and left named repeaters unnamed. - final identity = _overlayIdentityById[displayId]; - final match = (identity == null - ? null - : ExternalSurfaceGeoBuilder.resolveRepeater( - repeaters: _repeaters, - id: identity, - hopBytes: _hopBytes, - )) ?? - ExternalSurfaceGeoBuilder.resolveRepeater( - repeaters: _repeaters, - id: displayId, - hopBytes: _hopBytes, - ); - - final name = match?.name; - return name == null || name == 'Unknown' ? null : name; + // Why the display hash is tried as well as the identity: see + // resolveOverlayRow. + final match = ExternalSurfaceGeoBuilder.resolveOverlayRow( + displayId: displayId, + identity: _overlayIdentityById[displayId], + repeaters: _repeaters, + hopBytes: _hopBytes, + ); + if (match == null) return null; + // The user's nickname wins over the server's name. + final nickname = RepeaterNicknames.nicknameFor(match, _repeaterNicknames); + if (nickname != null) return nickname; + final name = match.name; + return name == 'Unknown' ? null : name; } // ============================================ @@ -3726,6 +3845,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[INIT] Loading preferences...'); await _loadPreferences(); await _loadRegionalCarpeaters(); + await _loadRepeaterNicknames(); await _loadRepeaterClaims(); await _loadScopeDiscoveryState(); await _loadLastRadioConfig(); @@ -12507,6 +12627,36 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _syncCarpeaterFilter(); } + Future _loadRepeaterNicknames() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + try { + _repeaterNicknames = + RepeaterNicknames.sanitize(box.get(_repeaterNicknamesKey)); + debugLog('[APP] Loaded ${_repeaterNicknames.length} repeater nicknames'); + } catch (e) { + debugError('[APP] Failed to load repeater nicknames: $e'); + _repeaterNicknames = const {}; + } + } + + Future _saveRepeaterNicknames() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) { + debugError('[APP] Repeater nicknames not saved: preferences unavailable'); + return; + } + try { + await box.put( + _repeaterNicknamesKey, + RepeaterNicknames.toStorage(_repeaterNicknames), + ); + await box.flush(); + } catch (e) { + debugError('[APP] Failed to save repeater nicknames: $e'); + } + } + Future _loadLastRadioConfig() async { final box = await _openBoxSafely(_preferencesBoxName); if (box == null) return; diff --git a/lib/screens/settings/map_settings_page.dart b/lib/screens/settings/map_settings_page.dart index 139e402e..99f8f0d2 100644 --- a/lib/screens/settings/map_settings_page.dart +++ b/lib/screens/settings/map_settings_page.dart @@ -7,6 +7,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import '../../providers/app_state_provider.dart'; import '../offline_maps_screen.dart'; +import 'repeater_nicknames_page.dart'; import 'settings_section_card.dart'; /// Settings folder: Map. @@ -17,6 +18,7 @@ class MapSettingsPage extends StatelessWidget { Widget build(BuildContext context) { final appState = context.watch(); final prefs = appState.preferences; + final nicknameCount = appState.repeaterNicknameCount; return Scaffold( appBar: AppBar( @@ -134,6 +136,24 @@ class MapSettingsPage extends StatelessWidget { prefs.copyWith(largerTopRepeaters: value)); }, ), + ListTile( + leading: const Icon(Icons.label_outline), + title: const Text('Repeater Nicknames'), + subtitle: Text(nicknameCount == 0 + ? 'Your own short names for Top Heard' + : nicknameCount == 1 + ? '1 nickname' + : '$nicknameCount nicknames'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const RepeaterNicknamesPage(), + ), + ); + }, + ), ]), ], ), diff --git a/lib/screens/settings/repeater_nicknames_page.dart b/lib/screens/settings/repeater_nicknames_page.dart new file mode 100644 index 00000000..58742c38 --- /dev/null +++ b/lib/screens/settings/repeater_nicknames_page.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../providers/app_state_provider.dart'; +import '../../utils/public_key.dart'; +import '../../utils/repeater_nicknames.dart'; +import '../../widgets/repeater_nickname_dialog.dart'; +import '../../widgets/repeater_nickname_widgets.dart'; +import '../../widgets/repeater_picker_sheet.dart'; +import 'settings_section_card.dart'; + +/// Settings > Map > Repeater Nicknames: every saved nickname, with add, edit +/// and remove. Entries may belong to repeaters outside the current zone, so +/// edits and removals go by key, not through the catalogue. +class RepeaterNicknamesPage extends StatefulWidget { + const RepeaterNicknamesPage({super.key}); + + @override + State createState() => _RepeaterNicknamesPageState(); +} + +class _RepeaterNicknamesPageState extends State { + final _removed = NicknameRemovalBatch(); + + @override + Widget build(BuildContext context) { + final entries = context.watch().repeaterNicknames; + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Repeater Nicknames', style: TextStyle(fontSize: 18)), + actions: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Add nickname', + onPressed: _add, + ), + ], + ), + body: RepeaterNicknameList( + entries: entries, + onAdd: _add, + onEdit: _edit, + onRemove: _remove, + ), + ); + } + + Future _add() async { + final appState = context.read(); + // Only repeaters with a full key can take a nickname, so the picker + // offers no others. + final picked = await showRepeaterPicker(context, + include: (repeater) => isFullPublicKey(repeater.hexId)); + if (!mounted || picked == null) return; + await editRepeaterNickname(context, appState, picked); + } + + Future _edit(String key, RepeaterNickname entry) async { + final appState = context.read(); + final result = await showRepeaterNicknameDialog( + context, + hex: entry.hex, + serverName: entry.name, + current: entry.nickname, + ); + // The save needs no context, so a page closed under the dialog still + // keeps what was typed. + if (!nicknameResultChanges(result, entry.nickname)) return; + await appState.setRepeaterNicknameByKey(key, result); + } + + Future _remove(String key, RepeaterNickname entry) async { + final appState = context.read(); + final messenger = ScaffoldMessenger.of(context); + await appState.setRepeaterNicknameByKey(key, null); + // A removal while the last one's bar is still up joins it, so one Undo + // brings both back. Replacing the bar alone used to lose the first. + final batch = _removed..add(key, entry); + messenger.hideCurrentSnackBar(); + messenger + .showSnackBar(SnackBar( + content: Text(batch.message), + // A snack bar with an action stays up until tapped unless told not + // to; this one is a short offer, not a question. + persist: false, + action: SnackBarAction( + label: 'Undo', + onPressed: () { + for (final removed in batch.take().entries) { + appState.setRepeaterNicknameByKey( + removed.key, + removed.value.nickname, + name: removed.value.name, + hex: removed.value.hex, + ); + } + }, + ), + )) + .closed + .then((reason) { + // Hidden means a newer removal replaced this bar and carries the batch + // on. Anything else ends the offer. + if (reason != SnackBarClosedReason.hide) batch.clear(); + }); + } +} + +/// The removals the Settings list's current Undo bar covers, so one Undo +/// restores every nickname removed while it was up. +class NicknameRemovalBatch { + final Map _entries = {}; + + /// Adds [entry], removed from the repeater with full key [key]. + void add(String key, RepeaterNickname entry) => _entries[key] = entry; + + /// "Removed Mell" for one, "Removed 2 nicknames" for more. + String get message => _entries.length == 1 + ? 'Removed ${_entries.values.single.nickname}' + : 'Removed ${_entries.length} nicknames'; + + /// Every removal in the batch, which is left empty. + Map take() { + final taken = Map.of(_entries); + _entries.clear(); + return taken; + } + + /// Ends the batch without restoring anything. + void clear() => _entries.clear(); +} + +/// The nickname list itself, fed by the page so it can be built without the +/// provider: the saved [entries] (full key, entry) one row each, hex first, +/// like the paper list taped in the car, or a prompt to add one. +class RepeaterNicknameList extends StatelessWidget { + const RepeaterNicknameList({ + super.key, + required this.entries, + required this.onEdit, + required this.onRemove, + required this.onAdd, + }); + + final List> entries; + final void Function(String key, RepeaterNickname entry) onEdit; + final void Function(String key, RepeaterNickname entry) onRemove; + final VoidCallback onAdd; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + if (entries.isEmpty) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.label_outline, + size: 40, color: scheme.onSurfaceVariant), + const SizedBox(height: 12), + Text( + 'Give repeaters your own short names, like Mell for 5007. ' + 'They show instead of the ID in Top Heard, and as the name ' + 'on the Live Activity, CarPlay, Apple Watch and Siri.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + icon: const Icon(Icons.add), + label: const Text('Add nickname'), + onPressed: onAdd, + ), + const SizedBox(height: 12), + Text( + 'You can also tap a repeater on the map.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall + ?.copyWith(color: scheme.onSurfaceVariant), + ), + ], + ), + ), + ); + } + + return ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + SettingsSectionCard(title: 'Nicknames', children: [ + for (final e in entries) + ListTile( + leading: RepeaterHexBadge(e.value.hex), + title: Text( + e.value.nickname, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: e.value.name.isEmpty ? null : Text(e.value.name), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Remove', + onPressed: () => onRemove(e.key, e.value), + ), + onTap: () => onEdit(e.key, e.value), + ), + ]), + ], + ); + } +} diff --git a/lib/services/app_intents/siri_snapshot_builder.dart b/lib/services/app_intents/siri_snapshot_builder.dart index 687345d4..94c0392e 100644 --- a/lib/services/app_intents/siri_snapshot_builder.dart +++ b/lib/services/app_intents/siri_snapshot_builder.dart @@ -1,5 +1,6 @@ import '../../models/log_entry.dart'; import '../../models/repeater.dart'; +import '../../utils/repeater_nicknames.dart'; import '../external_surfaces/geo/external_surface_geo_builder.dart'; import '../watch/watch_models.dart'; import 'siri_snapshot_models.dart'; @@ -34,6 +35,7 @@ class SiriSnapshotBuilder { required List repeaters, int? hopBytes, DateTime? now, + Map nicknames = const {}, }) { final cutoff = (now ?? DateTime.now()).subtract(maximumObservationAge); final candidates = <_SiriObservationCandidate>[]; @@ -44,18 +46,12 @@ class SiriSnapshotBuilder { Repeater? lookup(_SiriObservationCandidate candidate) { final cacheKey = _identityKey(candidate); if (repeaterCache.containsKey(cacheKey)) return repeaterCache[cacheKey]; - final found = (candidate.identity == null - ? null - : ExternalSurfaceGeoBuilder.resolveRepeater( - repeaters: repeaters, - id: candidate.identity!, - hopBytes: hopBytes, - )) ?? - ExternalSurfaceGeoBuilder.resolveRepeater( - repeaters: repeaters, - id: candidate.displayId.toUpperCase(), - hopBytes: hopBytes, - ); + final found = ExternalSurfaceGeoBuilder.resolveOverlayRow( + displayId: candidate.displayId, + identity: candidate.identity, + repeaters: repeaters, + hopBytes: hopBytes, + ); repeaterCache[cacheKey] = found; return found; } @@ -73,7 +69,13 @@ class SiriSnapshotBuilder { return SiriRepeaterObservation( entityId: resolved ? repeaterEntityId(repeater) : null, displayHexId: normalizedId, - name: resolved && repeater.name != 'Unknown' ? repeater.name : null, + // The user's nickname for the resolved repeater outranks the server's + // name. Only the observations carry it: the Shortcuts catalogue keeps + // server names. + name: (resolved + ? RepeaterNicknames.nicknameFor(repeater, nicknames) + : null) ?? + (resolved && repeater.name != 'Unknown' ? repeater.name : null), observedAt: candidate.observedAt, kind: candidate.kind, direct: candidate.direct, @@ -263,18 +265,12 @@ class SiriSnapshotBuilder { required int? hopBytes, }) { if (heardRepeaters.isEmpty) return null; - final match = (candidate.identity == null - ? null - : ExternalSurfaceGeoBuilder.resolveRepeater( - repeaters: heardRepeaters, - id: candidate.identity!, - hopBytes: hopBytes, - )) ?? - ExternalSurfaceGeoBuilder.resolveRepeater( - repeaters: heardRepeaters, - id: candidate.displayId.toUpperCase(), - hopBytes: hopBytes, - ); + final match = ExternalSurfaceGeoBuilder.resolveOverlayRow( + displayId: candidate.displayId, + identity: candidate.identity, + repeaters: heardRepeaters, + hopBytes: hopBytes, + ); return match == null ? null : repeaterEntityId(match); } diff --git a/lib/services/external_surfaces/geo/external_surface_geo_builder.dart b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart index d1e80563..8aee6732 100644 --- a/lib/services/external_surfaces/geo/external_surface_geo_builder.dart +++ b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart @@ -3,6 +3,7 @@ import '../../../models/ping_data.dart'; import '../../../models/repeater.dart'; import '../../../providers/app_state_provider.dart' show OverlayPingType; import '../../../utils/ping_colors.dart'; +import '../../../utils/repeater_nicknames.dart'; import '../external_surface_color.dart'; import 'external_surface_geo_models.dart'; @@ -289,6 +290,39 @@ class ExternalSurfaceGeoBuilder { return prefixed.length == 1 ? prefixed.single : null; } + /// Resolve one overlay row to a repeater: the fullest identity the row + /// arrived with first, then the display hash it is shown under. + /// + /// **The fallback is the point.** A discovery response's public key is a + /// better identity, but the catalogue is matched by a `hexId` that the API + /// frequently omits, and the display hash additionally resolves + /// through `displayHexId`, which falls back to the short numeric ID when + /// there is no hex at all. Trying only the longer form threw away the lookup + /// that was doing the work, and left named repeaters unnamed. An [identity] + /// that is null, empty or matches nothing (or more than one repeater) falls + /// through to [displayId]. + /// + /// Returns null unless exactly one repeater matches, as [resolveRepeater] + /// does: a confidently wrong name is worse than none. + static Repeater? resolveOverlayRow({ + required String displayId, + String? identity, + required List repeaters, + int? hopBytes, + }) => + (identity == null || identity.isEmpty + ? null + : resolveRepeater( + repeaters: repeaters, + id: identity, + hopBytes: hopBytes, + )) ?? + resolveRepeater( + repeaters: repeaters, + id: displayId.toUpperCase(), + hopBytes: hopBytes, + ); + /// Resolve overlay display hashes to repeaters, preferring the fuller /// identity the ping actually carried. /// @@ -382,6 +416,7 @@ class ExternalSurfaceGeoBuilder { required DateTime? rxAt, double? lat, double? lon, + Map nicknames = const {}, }) { final rows = []; @@ -397,6 +432,7 @@ class ExternalSurfaceGeoBuilder { at: topAt!, lat: lat, lon: lon, + nicknames: nicknames, )); } @@ -409,6 +445,7 @@ class ExternalSurfaceGeoBuilder { at: rxAt, lat: lat, lon: lon, + nicknames: nicknames, )); } @@ -423,6 +460,7 @@ class ExternalSurfaceGeoBuilder { required OverlayPingType type, required Map repeaterByHex, required DateTime at, + required Map nicknames, double? lat, double? lon, }) { @@ -441,8 +479,12 @@ class ExternalSurfaceGeoBuilder { return ExternalSurfaceHeardNode( id: hex, // Null rather than a guess: a short path hash can match several - // repeaters, and a confidently wrong name is worse than none. - name: repeater?.name, + // repeaters, and a confidently wrong name is worse than none. The user's + // own nickname for the resolved repeater outranks the server's name. + name: (repeater == null + ? null + : RepeaterNicknames.nicknameFor(repeater, nicknames)) ?? + repeater?.name, snr: snr, at: at, distanceM: distance, diff --git a/lib/utils/repeater_nicknames.dart b/lib/utils/repeater_nicknames.dart new file mode 100644 index 00000000..2250b318 --- /dev/null +++ b/lib/utils/repeater_nicknames.dart @@ -0,0 +1,172 @@ +/// Personal repeater nicknames: the pure store and resolution rules. +/// +/// A nickname is a short label the user gives one repeater (for example "Mell" +/// for the repeater whose key starts 5007). It lives only on the phone, keyed +/// by the repeater's FULL public key (the one stable identity a repeater has), +/// and takes priority over the server's name wherever a heard repeater is named +/// on the Top Heard overlay, the Live Activity, the Watch and Siri. +/// +/// **A nickname is shown only when the heard ID resolves to exactly one +/// repeater.** A short path hash routinely matches several repeaters, and a +/// confidently wrong name is worse than none. +library; + +import 'package:flutter/widgets.dart' show StringCharacters; + +import '../models/repeater.dart'; +import '../services/external_surfaces/geo/external_surface_geo_builder.dart'; +import 'public_key.dart'; + +/// One saved nickname, with a snapshot of the repeater it was given to. +/// +/// [name] and [hex] are captured when the nickname is saved so a Settings list +/// can still describe a repeater that is not in the current zone's catalogue. +/// They are display-only and never used to match a heard ID. +class RepeaterNickname { + const RepeaterNickname({ + required this.nickname, + required this.name, + required this.hex, + }); + + /// The user's short label, already through + /// [RepeaterNicknames.normalizeNickname]. + final String nickname; + + /// The server's repeater name at save time. + final String name; + + /// `Repeater.displayHexId()` at save time. + final String hex; + + @override + bool operator ==(Object other) => + other is RepeaterNickname && + other.nickname == nickname && + other.name == name && + other.hex == hex; + + @override + int get hashCode => Object.hash(nickname, name, hex); + + @override + String toString() => 'RepeaterNickname($nickname, $name, $hex)'; +} + +/// Static helpers over a nickname map keyed by upper-case 64-hex public key. +class RepeaterNicknames { + const RepeaterNicknames._(); + + /// The longest nickname, in user-perceived characters. + static const int maxLength = 8; + + /// [raw] trimmed, with every run of whitespace collapsed to one space and + /// capped at [maxLength] characters, or null when nothing is left. + /// + /// The cap counts user-perceived characters (grapheme clusters), so an emoji + /// or an accented letter is never cut in half. A cap that lands on a space + /// is trimmed again so the result is stable under a second pass. + /// + /// Zero-width spaces, word joiners and byte-order marks are dropped, and a + /// nickname of nothing but format characters (joiners, direction marks) is + /// blank: pasted, it would save a label nobody can see. Joiners inside an + /// emoji sequence are kept. + static String? normalizeNickname(String? raw) { + if (raw == null) return null; + final collapsed = + raw.replaceAll(_zeroWidth, '').trim().replaceAll(RegExp(r'\s+'), ' '); + final capped = collapsed.characters.take(maxLength).toString().trim(); + if (capped.replaceAll(_format, '').trim().isEmpty) return null; + return capped; + } + + static final RegExp _zeroWidth = RegExp('[\u200B\u2060\uFEFF]'); + static final RegExp _format = RegExp(r'\p{Cf}', unicode: true); + + /// The nicknames persisted in Hive: a map of key to a map holding + /// `nickname`, `name` and `hex`. + /// + /// Keys are stored in normalized form. An entry whose key is not a full + /// public key, or whose nickname is blank, is dropped. Anything that is not + /// a map, or a field that is not a string, is tolerated rather than thrown + /// on, so a corrupt box can never stop the app starting. + static Map sanitize(Object? raw) { + if (raw is! Map) return const {}; + final out = {}; + for (final entry in raw.entries) { + final key = entry.key; + final value = entry.value; + if (key is! String || value is! Map) continue; + final normalizedKey = normalizePublicKey(key); + if (normalizedKey == null) continue; + final rawNickname = value['nickname']; + final nickname = + rawNickname is String ? normalizeNickname(rawNickname) : null; + if (nickname == null) continue; + final name = value['name']; + final hex = value['hex']; + out[normalizedKey] = RepeaterNickname( + nickname: nickname, + name: name is String ? name : '', + // A damaged entry still shows an ID in the Settings list: the key's + // first four characters, the same fallback setRepeaterNicknameByKey + // uses. + hex: hex is String && hex.trim().isNotEmpty + ? hex + : normalizedKey.substring(0, 4), + ); + } + return out; + } + + /// [nicknames] as plain maps, the shape [sanitize] reads back from Hive. + static Map> toStorage( + Map nicknames, + ) => + { + for (final entry in nicknames.entries) + entry.key: { + 'nickname': entry.value.nickname, + 'name': entry.value.name, + 'hex': entry.value.hex, + }, + }; + + /// The nickname saved for [repeater], matched on its full public key, or + /// null when it has none or its `hexId` is not a full key (a repeater with an + /// empty or short `hexId` can never be told apart from another one). + static String? nicknameFor( + Repeater repeater, + Map nicknames, + ) { + final key = normalizePublicKey(repeater.hexId); + return key == null ? null : nicknames[key]?.nickname; + } + + /// The nickname for a heard row, or null when the row does not resolve to + /// exactly one repeater that has one. + /// + /// Resolves through [ExternalSurfaceGeoBuilder.resolveOverlayRow]: the + /// fullest [identity] the row arrived with first (a discovery response's + /// public key, a trace's target), then [displayId], the hash it is shown + /// under. + /// + /// An empty [nicknames] returns null without scanning [repeaters], which is + /// the common case and runs on every overlay rebuild. + static String? labelFor({ + required String displayId, + String? identity, + required List repeaters, + int? hopBytes, + required Map nicknames, + }) { + if (nicknames.isEmpty) return null; + final match = ExternalSurfaceGeoBuilder.resolveOverlayRow( + displayId: displayId, + identity: identity, + repeaters: repeaters, + hopBytes: hopBytes, + ); + return match == null ? null : nicknameFor(match, nicknames); + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 919b758e..f8f9b79d 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -41,6 +41,8 @@ import '../utils/serial_task_gate.dart'; import 'cell_summary_sheet.dart'; import 'repeater_admin_sheet.dart'; import 'repeater_id_chip.dart'; +import 'repeater_nickname_dialog.dart'; +import 'repeater_nickname_widgets.dart'; import 'repeater_scope_chips.dart'; import 'rx_path_chain.dart'; @@ -6747,8 +6749,10 @@ class _MapWidgetState extends State with WidgetsBindingObserver { }; } - /// Build a single overlay table row with colored dot, repeater ID, and SNR - TableRow _overlayRow(String repeaterId, double snr, Color dotColor, + /// Build a single overlay table row with colored dot, label, and SNR. + /// [label] is the user's nickname when the row resolves to one repeater + /// that has one, else the repeater's hex ID. + TableRow _overlayRow(String label, double snr, Color dotColor, {bool isLarge = false}) { final dotSize = isLarge ? 9.0 : 6.0; final fontSize = isLarge ? 17.0 : 11.0; @@ -6772,7 +6776,7 @@ class _MapWidgetState extends State with WidgetsBindingObserver { Padding( padding: EdgeInsets.symmetric(vertical: rowVerticalPadding), child: Text( - repeaterId, + label, style: TextStyle( fontSize: fontSize, fontWeight: FontWeight.w600, @@ -6843,17 +6847,18 @@ class _MapWidgetState extends State with WidgetsBindingObserver { defaultColumnWidth: const IntrinsicColumnWidth(), columnWidths: { 0: const IntrinsicColumnWidth(), // dot - 1: const IntrinsicColumnWidth(), // ID + 1: const IntrinsicColumnWidth(), // label 2: FixedColumnWidth(isLarge ? 12 : 8), // spacer 3: const IntrinsicColumnWidth(), // SNR }, children: [ for (final r in topRepeaters) - _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type), + _overlayRow(appState.topHeardLabel(r.repeaterId), r.snr, + _overlayTypeColor(r.type), isLarge: isLarge), if (rxSlot != null) - _overlayRow(rxSlot.repeaterId, rxSlot.snr, - _overlayTypeColor(OverlayPingType.rx), + _overlayRow(appState.topHeardLabel(rxSlot.repeaterId), + rxSlot.snr, _overlayTypeColor(OverlayPingType.rx), isLarge: isLarge), ], ), @@ -8809,7 +8814,8 @@ class _MapWidgetState extends State with WidgetsBindingObserver { iconColor: color, valueColor: color); } - /// Row-1 identity for the repeater pill: a status-coloured dot + the name. + /// Row-1 identity for the repeater pill: a status-coloured dot + the name, + /// then the user's nickname tag when it has one (edited from the sheet). Widget _repeaterPillTitle(Repeater repeater, Color statusColor) { final theme = Theme.of(context); return Row( @@ -8830,10 +8836,36 @@ class _MapWidgetState extends State with WidgetsBindingObserver { color: theme.colorScheme.onSurface), ), ), + // Selected rather than read once: the pill can outlive an edit. + Selector( + selector: (_, s) => s.nicknameForRepeater(repeater), + builder: (context, nickname, _) => nickname == null + ? const SizedBox.shrink() + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 6), + RepeaterNicknameTag(nickname: nickname), + ], + ), + ), ], ); } + /// The repeater sheet's Nickname button beside Manage: "Nickname" until + /// one is set, then the nickname. Opens the nickname dialog. Only built for + /// a repeater with a full key. + Widget _nicknameButton(AppStateProvider appState, Repeater repeater) { + return Selector( + selector: (_, s) => s.nicknameForRepeater(repeater), + builder: (context, nickname, _) => RepeaterNicknameButton( + nickname: nickname, + onPressed: () => editRepeaterNickname(context, appState, repeater), + ), + ); + } + /// Row-2 stats for the repeater pill: Max Range · Hop Bytes · Clock Sync · /// Last Heard. (Online/offline status is the coloured tower in the title.) /// Max Range comes from the lazy [statsFuture]. @@ -10796,25 +10828,42 @@ class _MapWidgetState extends State with WidgetsBindingObserver { const SizedBox(height: 14), // Manage: opens the admin sheet for this repeater. Disabled with // the reason when the key is short or the app cannot manage now. + // Beside it, for a repeater with a full key, the Nickname button + // (the nickname itself once one is set). A repeater without one + // can take no nickname, so Manage keeps the full width and its + // "Full key unknown" covers both. The reason spans the whole + // row: under a half-width Manage it wrapped to two lines beside + // empty space. Builder(builder: (context) { final block = appState.repeaterAdminBlockReason; final hasKey = isFullPublicKey(repeater.hexId); final hint = !hasKey ? 'Full key unknown' : block; + final manage = FilledButton.tonalIcon( + icon: + const Icon(Icons.admin_panel_settings_outlined, size: 18), + label: const Text('Manage'), + onPressed: hint == null + ? () { + Navigator.pop(context); + showRepeaterAdminSheet(this.context, + RepeaterTarget.fromRepeater(repeater)); + } + : null, + ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - FilledButton.tonalIcon( - icon: const Icon(Icons.admin_panel_settings_outlined, - size: 18), - label: const Text('Manage'), - onPressed: hint == null - ? () { - Navigator.pop(context); - showRepeaterAdminSheet(this.context, - RepeaterTarget.fromRepeater(repeater)); - } - : null, - ), + if (hasKey) + Row( + children: [ + Expanded(child: manage), + const SizedBox(width: 8), + Expanded( + child: _nicknameButton(appState, repeater)), + ], + ) + else + manage, if (hint != null) Padding( padding: const EdgeInsets.only(top: 4), diff --git a/lib/widgets/repeater_nickname_dialog.dart b/lib/widgets/repeater_nickname_dialog.dart new file mode 100644 index 00000000..8f8010cb --- /dev/null +++ b/lib/widgets/repeater_nickname_dialog.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; + +import '../models/repeater.dart'; +import '../providers/app_state_provider.dart'; +import '../utils/repeater_nicknames.dart'; +import 'repeater_nickname_widgets.dart'; + +/// Asks for [repeater]'s nickname and saves the answer, skipping a result +/// that changes nothing. The one flow behind the repeater sheet's Nickname +/// button and the Settings list's add. +/// +/// The save needs no context, so it does not wait on [context] still being +/// mounted: a screen closed under the dialog keeps what was typed. +Future editRepeaterNickname( + BuildContext context, + AppStateProvider appState, + Repeater repeater, +) async { + final current = appState.nicknameForRepeater(repeater); + final result = await showRepeaterNicknameDialog( + context, + hex: repeater.displayHexId(), + serverName: repeater.name == 'Unknown' ? '' : repeater.name, + current: current, + ); + if (!nicknameResultChanges(result, current)) return; + await appState.setRepeaterNickname(repeater, result); +} + +/// Asks for a nickname for the repeater shown as [hex] (named [serverName] by +/// the server), starting from [current]. +/// +/// Returns null when cancelled, '' when the user removed the nickname, +/// otherwise the normalized nickname. +Future showRepeaterNicknameDialog( + BuildContext context, { + required String hex, + required String serverName, + String? current, +}) { + return showDialog( + context: context, + builder: (_) => _RepeaterNicknameDialog( + hex: hex, + serverName: serverName, + current: current, + ), + ); +} + +/// Whether a [showRepeaterNicknameDialog] [result] would change a repeater +/// whose nickname is [current]. +/// +/// The provider setters save and sync on every call, so callers skip a +/// cancel, the same nickname again, and a removal of nothing. +bool nicknameResultChanges(String? result, String? current) { + if (result == null) return false; + if (result.isEmpty) return current != null; + return result != current; +} + +class _RepeaterNicknameDialog extends StatefulWidget { + const _RepeaterNicknameDialog({ + required this.hex, + required this.serverName, + this.current, + }); + + final String hex; + final String serverName; + final String? current; + + @override + State<_RepeaterNicknameDialog> createState() => + _RepeaterNicknameDialogState(); +} + +class _RepeaterNicknameDialogState extends State<_RepeaterNicknameDialog> { + late final TextEditingController _controller = + TextEditingController(text: widget.current ?? ''); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _save() { + Navigator.of(context) + .pop(RepeaterNicknames.normalizeNickname(_controller.text) ?? ''); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final hex = widget.hex; + final canRemove = widget.current != null; + + return AlertDialog( + title: const Text('Repeater Nickname'), + scrollable: true, + // The default 40 pt side inset left 232 pt for Remove, Cancel and Save, + // which pushed Remove onto its own row on 320 and 360 pt phones. At 16 + // (and Remove's padding trimmed below) all three fit from 320 up. + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + RepeaterHexBadge(hex), + if (widget.serverName.isNotEmpty) ...[ + const SizedBox(width: 8), + Flexible( + child: Text( + widget.serverName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium + ?.copyWith(color: scheme.onSurfaceVariant), + ), + ), + ], + ], + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + autofocus: true, + maxLength: RepeaterNicknames.maxLength, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: 'Nickname', + hintText: 'Mell', + // Faded: the hint is a real-looking nickname, and at the + // theme's full hint colour it read as one already typed in the + // dark theme. + hintStyle: TextStyle( + color: scheme.onSurfaceVariant.withValues(alpha: 0.6)), + border: const OutlineInputBorder(), + ), + onSubmitted: (_) => _save(), + ), + const SizedBox(height: 4), + Text( + 'Shows instead of $hex in Top Heard, and as its name on the Live ' + 'Activity, CarPlay, Apple Watch and Siri. If another repeater ' + 'shares $hex, the ID shows instead.', + style: theme.textTheme.bodySmall + ?.copyWith(color: scheme.onSurfaceVariant), + ), + ], + ), + actionsAlignment: + canRemove ? MainAxisAlignment.spaceBetween : MainAxisAlignment.end, + actions: [ + if (canRemove) + TextButton( + style: TextButton.styleFrom( + foregroundColor: scheme.error, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + onPressed: () => Navigator.of(context).pop(''), + child: const Text('Remove'), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _save, + child: const Text('Save'), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/repeater_nickname_widgets.dart b/lib/widgets/repeater_nickname_widgets.dart new file mode 100644 index 00000000..4c4fc36f --- /dev/null +++ b/lib/widgets/repeater_nickname_widgets.dart @@ -0,0 +1,133 @@ +/// The pieces the repeater nickname UI is drawn from: the repeater's hex ID +/// and the user's own name for it, side by side the way a wardriver keeps +/// them on a paper list ("5007 Mell"), and the sheet's Nickname button. +library; + +import 'package:flutter/material.dart'; + +import '../utils/repeater_marker_style.dart'; + +/// Fill and border alphas of the app's tinted status pills (the zone and +/// TX/RX counters under the title, `status_bar.dart`), so a nickname tag and +/// the Nickname button read as one of them. +const double _tagFillAlpha = 0.15; +const double _tagBorderAlpha = 0.4; + +/// A repeater's hex ID as Top Heard shows it, in a hairline box with the map +/// chip's corner radius. No fill, so it sits on any surface. The border is the +/// muted text role at half strength: the app's scheme sets no +/// `outlineVariant`, and its `outline` all but vanishes on the dark cards. +class RepeaterHexBadge extends StatelessWidget { + const RepeaterHexBadge(this.hex, {super.key}); + + final String hex; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(RepeaterMarkerStyle.chipCornerRadius), + border: + Border.all(color: scheme.onSurfaceVariant.withValues(alpha: 0.5)), + ), + child: Text( + hex, + maxLines: 1, + softWrap: false, + style: TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + fontSize: 13, + color: scheme.onSurface, + ), + ), + ); + } +} + +/// The user's nickname for a repeater, as a small sky-tinted pill: the map +/// pill shows it after the repeater's name. Draws nothing when [nickname] is +/// null. +class RepeaterNicknameTag extends StatelessWidget { + const RepeaterNicknameTag({super.key, required this.nickname}); + + final String? nickname; + + @override + Widget build(BuildContext context) { + final name = nickname; + if (name == null) return const SizedBox.shrink(); + final scheme = Theme.of(context).colorScheme; + final hue = scheme.secondary; + // Sky-600 on its own tint is too dim on the dark slate surfaces, so the + // text is lifted toward the theme's text colour there; the fill and the + // border stay on the true hue. + final fg = Theme.of(context).brightness == Brightness.dark + ? Color.lerp(hue, scheme.onSurface, 0.45)! + : hue; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: hue.withValues(alpha: _tagFillAlpha), + borderRadius: + BorderRadius.circular(RepeaterMarkerStyle.chipCornerRadius), + border: Border.all(color: hue.withValues(alpha: _tagBorderAlpha)), + ), + child: Text( + name, + maxLines: 1, + softWrap: false, + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: fg), + ), + ); + } +} + +/// The repeater sheet's Nickname button, beside Manage and the same height +/// and shape. Reads "Nickname" until one is set, then the nickname itself. +class RepeaterNicknameButton extends StatelessWidget { + const RepeaterNicknameButton({ + super.key, + required this.nickname, + required this.onPressed, + }); + + final String? nickname; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final name = nickname; + // Named for what it does: a bare "Mell, button" would not say the + // button edits it. + return Semantics( + button: true, + label: name == null ? 'Add nickname' : 'Edit nickname $name', + onTap: onPressed, + excludeSemantics: true, + child: OutlinedButton.icon( + // The outline takes the status pills' border recipe in the button's + // own colour. The theme default (`outline`) is slate-700 on the dark + // sheet and all but disappears. + style: OutlinedButton.styleFrom( + side: BorderSide( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: _tagBorderAlpha), + ), + ), + icon: Icon(name == null ? Icons.add : Icons.label_outline, size: 18), + label: Text( + name ?? 'Nickname', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onPressed: onPressed, + ), + ); + } +} diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart index e7258590..028daaae 100644 --- a/lib/widgets/repeater_picker_sheet.dart +++ b/lib/widgets/repeater_picker_sheet.dart @@ -9,7 +9,12 @@ 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) { +/// [include], when given, limits the list to the repeaters it accepts, so a +/// caller never offers one it would then have to refuse. +Future showRepeaterPicker( + BuildContext context, { + bool Function(Repeater repeater)? include, +}) { return showModalBottomSheet( context: context, isScrollControlled: true, @@ -25,6 +30,7 @@ Future showRepeaterPicker(BuildContext context) { expand: false, builder: (context, scrollController) => _RepeaterPickerBody( scrollController: scrollController, + include: include, ), ), ); @@ -32,8 +38,9 @@ Future showRepeaterPicker(BuildContext context) { class _RepeaterPickerBody extends StatefulWidget { final ScrollController scrollController; + final bool Function(Repeater repeater)? include; - const _RepeaterPickerBody({required this.scrollController}); + const _RepeaterPickerBody({required this.scrollController, this.include}); @override State<_RepeaterPickerBody> createState() => _RepeaterPickerBodyState(); @@ -104,7 +111,10 @@ class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { @override Widget build(BuildContext context) { final appState = context.watch(); - final repeaters = appState.repeaters; + final include = widget.include; + final repeaters = include == null + ? appState.repeaters + : appState.repeaters.where(include).toList(); final position = appState.currentPosition; final isImperial = appState.preferences.isImperial; final colorScheme = Theme.of(context).colorScheme; diff --git a/test/screens/settings/nickname_removal_batch_test.dart b/test/screens/settings/nickname_removal_batch_test.dart new file mode 100644 index 00000000..44bd3311 --- /dev/null +++ b/test/screens/settings/nickname_removal_batch_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/screens/settings/repeater_nicknames_page.dart'; +import 'package:mesh_mapper/utils/repeater_nicknames.dart'; + +void main() { + final keyA = '5007${'A' * 60}'; + final keyB = '40E6${'B' * 60}'; + const mell = RepeaterNickname( + nickname: 'Mell', name: 'Mellionnec Repeater', hex: '5007'); + const goar = RepeaterNickname(nickname: 'Goar', name: 'Goariva', hex: '40E6'); + + test('one removal is named', () { + final batch = NicknameRemovalBatch()..add(keyA, mell); + expect(batch.message, 'Removed Mell'); + }); + + test('a second removal joins the first, and Undo gets both', () { + final batch = NicknameRemovalBatch() + ..add(keyA, mell) + ..add(keyB, goar); + expect(batch.message, 'Removed 2 nicknames'); + + final taken = batch.take(); + expect(taken, {keyA: mell, keyB: goar}); + expect(batch.take(), isEmpty, reason: 'take leaves the batch empty'); + }); + + test('clear ends the batch without restoring', () { + final batch = NicknameRemovalBatch()..add(keyA, mell); + batch.clear(); + expect(batch.take(), isEmpty); + }); +} diff --git a/test/screens/settings/repeater_nickname_list_test.dart b/test/screens/settings/repeater_nickname_list_test.dart new file mode 100644 index 00000000..a49f260e --- /dev/null +++ b/test/screens/settings/repeater_nickname_list_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/screens/settings/repeater_nicknames_page.dart'; +import 'package:mesh_mapper/utils/repeater_nicknames.dart'; + +final String _mellKey = '5007${'A' * 60}'; +final String _goarKey = '40E6${'B' * 60}'; + +const _mell = RepeaterNickname( + nickname: 'Mell', + name: 'Mellionnec Repeater', + hex: '5007', +); +const _goar = RepeaterNickname(nickname: 'Goar', name: '', hex: '40E6'); + +void main() { + final edits = <(String, RepeaterNickname)>[]; + final removals = <(String, RepeaterNickname)>[]; + var adds = 0; + + setUp(() { + edits.clear(); + removals.clear(); + adds = 0; + }); + + Widget host(List> entries) => MaterialApp( + home: Scaffold( + body: RepeaterNicknameList( + entries: entries, + onEdit: (key, entry) => edits.add((key, entry)), + onRemove: (key, entry) => removals.add((key, entry)), + onAdd: () => adds++, + ), + ), + ); + + testWidgets('the empty state explains and offers Add', (tester) async { + await tester.pumpWidget(host(const [])); + + expect( + find.text('Give repeaters your own short names, like Mell for 5007. ' + 'They show instead of the ID in Top Heard, and as the name on the ' + 'Live Activity, CarPlay, Apple Watch and Siri.'), + findsOneWidget, + ); + expect( + find.text('You can also tap a repeater on the map.'), findsOneWidget); + + await tester.tap(find.text('Add nickname')); + expect(adds, 1); + }); + + testWidgets('each entry shows its hex, nickname and server name', + (tester) async { + await tester.pumpWidget(host([MapEntry(_mellKey, _mell)])); + + expect(find.text('Nicknames'), findsOneWidget); + expect(find.text('5007'), findsOneWidget); + expect(find.text('Mell'), findsOneWidget); + expect(find.text('Mellionnec Repeater'), findsOneWidget); + expect(find.text('Add nickname'), findsNothing); + }); + + testWidgets('an entry with no server name has no subtitle', (tester) async { + await tester.pumpWidget(host([MapEntry(_goarKey, _goar)])); + + final tile = tester.widget(find.byType(ListTile)); + expect(tile.subtitle, isNull); + }); + + testWidgets('delete removes that entry by key', (tester) async { + await tester.pumpWidget(host([ + MapEntry(_mellKey, _mell), + MapEntry(_goarKey, _goar), + ])); + + await tester.tap(find.byTooltip('Remove').last); + + expect(removals, [(_goarKey, _goar)]); + expect(edits, isEmpty); + }); + + testWidgets('tapping an entry edits it', (tester) async { + await tester.pumpWidget(host([ + MapEntry(_mellKey, _mell), + MapEntry(_goarKey, _goar), + ])); + + await tester.tap(find.text('Mell')); + + expect(edits, [(_mellKey, _mell)]); + expect(removals, isEmpty); + }); +} diff --git a/test/services/app_intents/siri_snapshot_builder_test.dart b/test/services/app_intents/siri_snapshot_builder_test.dart index cbf56680..65737d27 100644 --- a/test/services/app_intents/siri_snapshot_builder_test.dart +++ b/test/services/app_intents/siri_snapshot_builder_test.dart @@ -4,6 +4,7 @@ import 'package:mesh_mapper/models/repeater.dart'; import 'package:mesh_mapper/services/app_intents/siri_snapshot_builder.dart'; import 'package:mesh_mapper/services/app_intents/siri_snapshot_models.dart'; import 'package:mesh_mapper/services/watch/watch_models.dart'; +import 'package:mesh_mapper/utils/repeater_nicknames.dart'; void main() { final now = DateTime.fromMillisecondsSinceEpoch(1787535920000); @@ -708,4 +709,53 @@ void main() { expect(catalog.first.id, 'SEA|repeater-7'); expect(catalog[1].id, 'SEA|repeater-0'); }); + + test('a nickname names the observation but not the Shortcuts catalogue', () { + final fullKey = '4E5D82AA'.padRight(64, '0'); + final keyed = Repeater( + id: 'database-123', + hexId: fullKey, + name: 'Capitol Hill', + lat: 47.6202, + lon: -122.3194, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + final tx = [ + TxLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + power: 1, + events: [RxEvent(repeaterId: '4E5D', snr: 8.5, rssi: -91)], + ), + ]; + SiriRecentHeard heard(Map nicknames) => + SiriSnapshotBuilder.buildRecentHeard( + txEntries: tx, + rxEntries: const [], + discoveryEntries: const [], + traceEntries: const [], + repeaters: [keyed], + hopBytes: 2, + now: now, + nicknames: nicknames, + ); + + final nicknamed = heard({ + fullKey: const RepeaterNickname( + nickname: 'Mell', name: 'Capitol Hill', hex: '4E5D82AA'), + }).observations.single; + expect(nicknamed.name, 'Mell'); + expect(nicknamed.entityId, 'SEA|database-123', + reason: 'a nickname never changes the entity identity'); + + expect(heard(const {}).observations.single.name, 'Capitol Hill', + reason: 'without nicknames the server name is unchanged'); + + expect(SiriSnapshotBuilder.buildRepeaterCatalog([keyed]).single.name, + 'Capitol Hill', + reason: 'Shortcuts entities keep the server name'); + }); } diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index 476ca92f..6c85735d 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -7,6 +7,7 @@ import 'package:mesh_mapper/providers/app_state_provider.dart' import 'package:mesh_mapper/services/watch/watch_geo_builder.dart'; import 'package:mesh_mapper/services/watch/watch_models.dart'; import 'package:mesh_mapper/utils/ping_colors.dart'; +import 'package:mesh_mapper/utils/repeater_nicknames.dart'; TxPing _tx(DateTime at, {List heard = const []}) => TxPing( latitude: 47.6, @@ -593,6 +594,47 @@ void main() { reason: 'a confidently wrong name is worse than none'); expect(built.single.distanceM, isNull); }); + + test('a nickname replaces the name only on the repeater it belongs to', () { + final keyedKey = '4E5D82AA'.padRight(64, '0'); + final capitol = _repeater( + id: '01', + hexId: keyedKey, + name: 'Capitol Hill', + lat: 47.61, + lon: -122.3); + final beacon = _repeater( + id: '02', + hexId: '77A1B0CC'.padRight(64, '0'), + name: 'Beacon Hill', + lat: 47.62, + lon: -122.3); + + final built = WatchGeoBuilder.buildHeard( + top: const [ + (repeaterId: '4E5D', snr: 8, type: OverlayPingType.tx), + (repeaterId: '77A1', snr: 6, type: OverlayPingType.tx), + (repeaterId: 'AB', snr: 4, type: OverlayPingType.tx), + ], + repeaterByHex: {'4E5D': capitol, '77A1': beacon}, + topAt: DateTime(2026, 8, 12), + rxAt: null, + lat: 47.6, + lon: -122.3, + nicknames: { + keyedKey: const RepeaterNickname( + nickname: 'Mell', name: 'Capitol Hill', hex: '4E5D'), + }, + ); + + expect(built[0].name, 'Mell'); + expect(built[0].distanceM, isNotNull, + reason: 'a nickname changes the label, never the match'); + expect(built[1].name, 'Beacon Hill', + reason: 'a repeater without a nickname keeps the server name'); + expect(built[2].name, isNull, + reason: 'an unresolved hash stays unnamed, nicknames or not'); + }); }); group('resolveRepeater', () { @@ -657,6 +699,98 @@ void main() { }); }); + group('resolveOverlayRow - identity first, then the display hash', () { + // Two repeaters sharing a leading byte, so the one-byte hash alone is + // genuinely ambiguous. + final capitol = _repeater( + id: '01', + hexId: '4E5D82AA', + name: 'Capitol Hill', + lat: 47.6, + lon: -122.3, + ); + final beacon = _repeater( + id: '02', + hexId: '4E99F1BB', + name: 'Beacon Hill', + lat: 47.5, + lon: -122.3, + ); + final other = _repeater( + id: '03', + hexId: '9C12D4EE', + name: 'Queen Anne', + lat: 47.7, + lon: -122.3, + ); + + test('the identity wins when the display hash is shared', () { + expect( + WatchGeoBuilder.resolveOverlayRow( + displayId: '4E', + repeaters: [capitol, beacon], + hopBytes: 1, + ), + isNull, + reason: 'the hash alone is genuinely ambiguous', + ); + + expect( + WatchGeoBuilder.resolveOverlayRow( + displayId: '4E', + identity: '4E99F1BB2233445566', + repeaters: [capitol, beacon], + hopBytes: 1, + )?.name, + 'Beacon Hill', + ); + }); + + test('a missing identity falls back to the display hash', () { + expect( + WatchGeoBuilder.resolveOverlayRow( + displayId: '9c', + repeaters: [capitol, beacon, other], + hopBytes: 1, + )?.name, + 'Queen Anne', + reason: 'the display hash is matched case-insensitively', + ); + expect( + WatchGeoBuilder.resolveOverlayRow( + displayId: '9C', + identity: '', + repeaters: [capitol, beacon, other], + hopBytes: 1, + )?.name, + 'Queen Anne', + reason: 'an empty identity is no identity', + ); + }); + + test('an identity that resolves to nothing falls back to the hash', () { + expect( + WatchGeoBuilder.resolveOverlayRow( + displayId: '9C', + identity: 'FFFFFFFF0011', + repeaters: [capitol, beacon, other], + hopBytes: 1, + )?.name, + 'Queen Anne', + ); + expect( + WatchGeoBuilder.resolveOverlayRow( + displayId: '4E', + identity: 'FFFFFFFF0011', + repeaters: [capitol, beacon, other], + hopBytes: 1, + ), + isNull, + reason: 'the fallback keeps the uniqueness rule', + ); + }); + }); + group('resolveOverlayRepeaters — name from the identity the ping carried', () { // Two repeaters sharing a leading byte. With one hop byte the wrist shows diff --git a/test/utils/repeater_nicknames_test.dart b/test/utils/repeater_nicknames_test.dart new file mode 100644 index 00000000..ee2f48ce --- /dev/null +++ b/test/utils/repeater_nicknames_test.dart @@ -0,0 +1,343 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/repeater.dart'; +import 'package:mesh_mapper/utils/repeater_nicknames.dart'; + +/// A full 64-hex public key that starts with [prefix]. +String _key(String prefix) => prefix.toUpperCase().padRight(64, '0'); + +Repeater _repeater( + String id, + String hexId, { + int hopBytes = 1, + String name = 'Rep', +}) => + Repeater( + id: id, + hexId: hexId, + name: name, + lat: 47.6, + lon: -122.3, + lastHeard: 1787535900, + enabled: 1, + hopBytes: hopBytes, + ); + +RepeaterNickname _nick(String nickname, + {String name = 'Rep', String hex = ''}) => + RepeaterNickname(nickname: nickname, name: name, hex: hex); + +void main() { + group('normalizeNickname', () { + test('trims and collapses whitespace runs to one space', () { + expect(RepeaterNicknames.normalizeNickname(' Mell '), 'Mell'); + expect(RepeaterNicknames.normalizeNickname('Me Kr'), 'Me Kr'); + expect(RepeaterNicknames.normalizeNickname('Me\t\n Kr'), 'Me Kr'); + }); + + test('caps at ${RepeaterNicknames.maxLength} characters', () { + expect(RepeaterNicknames.maxLength, 8); + expect(RepeaterNicknames.normalizeNickname('ABCDEFGHIJ'), 'ABCDEFGH'); + expect(RepeaterNicknames.normalizeNickname('ABCDEFGH'), 'ABCDEFGH'); + }); + + test('a cap that lands on a space leaves no trailing space', () { + final once = RepeaterNicknames.normalizeNickname('abcdefg hij'); + expect(once, 'abcdefg'); + expect(RepeaterNicknames.normalizeNickname(once), once, + reason: 'normalizing a stored nickname again must not change it'); + }); + + test('blank and null give null', () { + expect(RepeaterNicknames.normalizeNickname(' '), isNull); + expect(RepeaterNicknames.normalizeNickname(''), isNull); + expect(RepeaterNicknames.normalizeNickname('\t\n'), isNull); + expect(RepeaterNicknames.normalizeNickname(null), isNull); + }); + + test('a nickname nobody could see is blank', () { + expect(RepeaterNicknames.normalizeNickname('\u200B\u200B'), isNull); + expect(RepeaterNicknames.normalizeNickname('\u200D'), isNull); + expect(RepeaterNicknames.normalizeNickname('\u200E \uFEFF'), isNull); + }); + + test('zero-width spaces are dropped from a visible nickname', () { + expect(RepeaterNicknames.normalizeNickname('\u200BMe\u200Bll'), 'Mell'); + }); + + test('counts user-perceived characters, not code units', () { + // Each smiley is two UTF-16 code units; a code-unit cap would keep four. + expect(RepeaterNicknames.normalizeNickname('😀' * 10), '😀' * 8); + + // A family emoji is seven code points joined by zero-width joiners but + // one character to the user. + const family = '👨‍👩‍👧'; + expect(RepeaterNicknames.normalizeNickname(family * 9), family * 8); + + // A combining accent is a separate code point on the letter before it. + const accented = 'é'; + expect(RepeaterNicknames.normalizeNickname(accented * 10), accented * 8); + }); + }); + + group('sanitize', () { + final keyA = _key('5007'); + + test('keeps a valid entry', () { + final out = RepeaterNicknames.sanitize({ + keyA: {'nickname': 'Mell', 'name': 'Capitol Hill', 'hex': '5007'}, + }); + + expect(out, { + keyA: _nick('Mell', name: 'Capitol Hill', hex: '5007'), + }); + }); + + test('normalizes the key from lower case and from a 0x prefix', () { + final out = RepeaterNicknames.sanitize({ + keyA.toLowerCase(): {'nickname': 'Mell', 'name': 'A', 'hex': '5007'}, + '0x${_key('9c12').toLowerCase()}': { + 'nickname': 'Kr', + 'name': 'B', + 'hex': '9C12', + }, + }); + + expect(out.keys, unorderedEquals([keyA, _key('9c12')])); + expect(out[keyA]?.nickname, 'Mell'); + expect(out[_key('9c12')]?.nickname, 'Kr'); + }); + + test('drops a short key, a blank nickname and a non-map value', () { + final out = RepeaterNicknames.sanitize({ + '5007': {'nickname': 'Short', 'name': 'A', 'hex': '5007'}, + _key('1111'): {'nickname': ' ', 'name': 'B', 'hex': '1111'}, + _key('2222'): 'Mell', + _key('3333'): null, + _key('4444'): {'nickname': 'Keep', 'name': 'C', 'hex': '4444'}, + }); + + expect(out.keys, [_key('4444')]); + }); + + test('tolerates garbage without throwing', () { + expect(RepeaterNicknames.sanitize(null), isEmpty); + expect(RepeaterNicknames.sanitize('nope'), isEmpty); + expect(RepeaterNicknames.sanitize(['a', 'b']), isEmpty); + expect(RepeaterNicknames.sanitize(42), isEmpty); + + final out = RepeaterNicknames.sanitize({ + 7: {'nickname': 'IntKey', 'name': 'A', 'hex': 'A'}, + _key('5555'): {'nickname': 9, 'name': 'B', 'hex': 'B'}, + _key('6666'): {'nickname': 'Fine', 'name': 12, 'hex': null}, + }); + + expect(out.keys, [_key('6666')]); + expect(out[_key('6666')], _nick('Fine', name: '', hex: '6666'), + reason: 'a non-string name falls back to empty, a non-string hex ' + 'to the key prefix'); + }); + + test('a missing or blank hex falls back to the key prefix', () { + final out = RepeaterNicknames.sanitize({ + _key('7A7A'): {'nickname': 'NoHex', 'name': 'A'}, + _key('8B8B'): {'nickname': 'Blank', 'name': 'B', 'hex': ' '}, + }); + + expect(out[_key('7A7A')]?.hex, '7A7A'); + expect(out[_key('8B8B')]?.hex, '8B8B'); + }); + + test('re-caps a stored nickname that is too long', () { + final out = RepeaterNicknames.sanitize({ + keyA: {'nickname': ' ABCDEFGHIJ ', 'name': 'A', 'hex': '5007'}, + }); + + expect(out[keyA]?.nickname, 'ABCDEFGH'); + }); + }); + + test('toStorage round-trips through sanitize', () { + final nicknames = { + _key('5007'): _nick('Mell', name: 'Capitol Hill', hex: '5007'), + _key('9C12'): _nick('Me Kr', name: 'Queen Anne', hex: '9C12'), + }; + + final stored = RepeaterNicknames.toStorage(nicknames); + + expect(stored[_key('5007')], { + 'nickname': 'Mell', + 'name': 'Capitol Hill', + 'hex': '5007', + }); + expect(RepeaterNicknames.sanitize(stored), nicknames); + }); + + group('nicknameFor', () { + final keyA = _key('5007'); + final nicknames = {keyA: _nick('Mell')}; + + test('matches a repeater whose hexId is lower case', () { + expect( + RepeaterNicknames.nicknameFor( + _repeater('r1', keyA.toLowerCase()), nicknames), + 'Mell', + ); + expect( + RepeaterNicknames.nicknameFor(_repeater('r1', keyA), nicknames), + 'Mell', + ); + }); + + test('returns null for an empty or short hexId', () { + expect( + RepeaterNicknames.nicknameFor( + _repeater('r1', ''), {'': _nick('Ghost'), ...nicknames}), + isNull, + reason: 'an empty key in the map must not name a repeater with no key', + ); + expect( + RepeaterNicknames.nicknameFor(_repeater('r1', '5007'), nicknames), + isNull, + reason: 'a short hexId is not a unique identity', + ); + }); + + test('returns null for a repeater with no nickname', () { + expect( + RepeaterNicknames.nicknameFor(_repeater('r2', _key('9C12')), nicknames), + isNull, + ); + }); + }); + + group('labelFor', () { + // Two repeaters share the leading byte 50, so a one-byte hash cannot tell + // them apart. Their two-byte hashes differ. + final keyMell = _key('5007'); + final keyOther = _key('5099'); + final mell = _repeater('r1', keyMell, hopBytes: 2, name: 'Capitol Hill'); + final other = _repeater('r2', keyOther, hopBytes: 2, name: 'Queen Anne'); + final nicknames = {keyMell: _nick('Mell', name: 'Capitol Hill')}; + + test('a unique 2-byte display hash gives the nickname', () { + expect( + RepeaterNicknames.labelFor( + displayId: '5007', + repeaters: [mell, other], + hopBytes: 2, + nicknames: nicknames, + ), + 'Mell', + ); + expect( + RepeaterNicknames.labelFor( + displayId: '5007'.toLowerCase(), + repeaters: [mell, other], + hopBytes: 2, + nicknames: nicknames, + ), + 'Mell', + reason: 'the display hash is matched case-insensitively', + ); + }); + + test('a 1-byte hash shared by two repeaters gives null', () { + expect( + RepeaterNicknames.labelFor( + displayId: '50', + repeaters: [mell, other], + hopBytes: 1, + nicknames: nicknames, + ), + isNull, + reason: 'a confidently wrong name is worse than none', + ); + }); + + test("the shared hash plus one repeater's discovery key names it", () { + expect( + RepeaterNicknames.labelFor( + displayId: '50', + identity: keyMell, + repeaters: [mell, other], + hopBytes: 1, + nicknames: nicknames, + ), + 'Mell', + ); + expect( + RepeaterNicknames.labelFor( + displayId: '50', + identity: keyOther, + repeaters: [mell, other], + hopBytes: 1, + nicknames: nicknames, + ), + isNull, + reason: 'the other repeater resolves but has no nickname', + ); + }); + + test('an identity that resolves to nothing falls back to the hash', () { + expect( + RepeaterNicknames.labelFor( + displayId: '5007', + identity: _key('FFFF'), + repeaters: [mell, other], + hopBytes: 2, + nicknames: nicknames, + ), + 'Mell', + ); + }); + + test('a repeater with an empty hexId is never matched', () { + final noHex = _repeater('50', '', name: 'Site Without Hex'); + + expect( + RepeaterNicknames.labelFor( + displayId: '50', + repeaters: [noHex], + hopBytes: 1, + nicknames: {'': _nick('Ghost'), ...nicknames}, + ), + isNull, + reason: 'it resolves by its short id, but a nickname needs a full key', + ); + expect( + RepeaterNicknames.labelFor( + displayId: '5007', + repeaters: [noHex, mell], + hopBytes: 2, + nicknames: {'': _nick('Ghost'), ...nicknames}, + ), + 'Mell', + reason: 'the empty entry neither blocks nor steals the lookup', + ); + }); + + test('empty nicknames give null', () { + expect( + RepeaterNicknames.labelFor( + displayId: '5007', + repeaters: [mell, other], + hopBytes: 2, + nicknames: const {}, + ), + isNull, + ); + }); + + test('a resolved repeater with no nickname gives null', () { + expect( + RepeaterNicknames.labelFor( + displayId: '5099', + repeaters: [mell, other], + hopBytes: 2, + nicknames: nicknames, + ), + isNull, + ); + }); + }); +} diff --git a/test/widgets/repeater_nickname_dialog_test.dart b/test/widgets/repeater_nickname_dialog_test.dart new file mode 100644 index 00000000..55bd4a18 --- /dev/null +++ b/test/widgets/repeater_nickname_dialog_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/widgets/repeater_nickname_dialog.dart'; + +/// What the dialog handed back, once it has closed. +class _Outcome { + bool closed = false; + String? result; +} + +void main() { + /// Opens the dialog over a throwaway page. + Future<_Outcome> openDialog(WidgetTester tester, {String? current}) async { + final outcome = _Outcome(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + outcome.result = await showRepeaterNicknameDialog( + context, + hex: '5007', + serverName: 'Mellionnec Repeater', + current: current, + ); + outcome.closed = true; + }, + child: const Text('open'), + ), + ), + ), + )); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return outcome; + } + + testWidgets('shows the hex, the server name and the helper', (tester) async { + await openDialog(tester); + + expect(find.text('Repeater Nickname'), findsOneWidget); + expect(find.text('5007'), findsOneWidget); + expect(find.text('Mellionnec Repeater'), findsOneWidget); + expect( + find.text('Shows instead of 5007 in Top Heard, and as its name on the ' + 'Live Activity, CarPlay, Apple Watch and Siri. If another repeater ' + 'shares 5007, the ID shows instead.'), + findsOneWidget, + ); + }); + + testWidgets('saving a long name returns it capped at 8', (tester) async { + final outcome = await openDialog(tester); + + await tester.enterText(find.byType(TextField), 'Mellionnec'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(outcome.closed, isTrue); + expect(outcome.result, 'Mellionn'); + }); + + testWidgets('submitting the field saves', (tester) async { + final outcome = await openDialog(tester); + + await tester.enterText(find.byType(TextField), ' Goar '); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(outcome.result, 'Goar'); + }); + + testWidgets('cancel returns null', (tester) async { + final outcome = await openDialog(tester, current: 'Mell'); + + await tester.enterText(find.byType(TextField), 'Other'); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(outcome.closed, isTrue); + expect(outcome.result, isNull); + }); + + testWidgets('Remove is offered only for a current nickname', (tester) async { + await openDialog(tester); + expect(find.text('Remove'), findsNothing); + }); + + testWidgets('Remove returns an empty string', (tester) async { + final outcome = await openDialog(tester, current: 'Mell'); + + final field = tester.widget(find.byType(TextField)); + expect(field.controller!.text, 'Mell'); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + + expect(outcome.closed, isTrue); + expect(outcome.result, ''); + }); + + testWidgets('a blank save returns an empty string', (tester) async { + final outcome = await openDialog(tester, current: 'Mell'); + + await tester.enterText(find.byType(TextField), ' '); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(outcome.result, ''); + }); + + group('nicknameResultChanges', () { + test('a cancel changes nothing', () { + expect(nicknameResultChanges(null, null), isFalse); + expect(nicknameResultChanges(null, 'Mell'), isFalse); + }); + + test('the same nickname again changes nothing', () { + expect(nicknameResultChanges('Mell', 'Mell'), isFalse); + }); + + test('removing a nickname that is not there changes nothing', () { + expect(nicknameResultChanges('', null), isFalse); + }); + + test('a new, different or removed nickname is a change', () { + expect(nicknameResultChanges('Mell', null), isTrue); + expect(nicknameResultChanges('Goar', 'Mell'), isTrue); + expect(nicknameResultChanges('', 'Mell'), isTrue); + }); + }); +} diff --git a/test/widgets/repeater_nickname_widgets_test.dart b/test/widgets/repeater_nickname_widgets_test.dart new file mode 100644 index 00000000..c3049f65 --- /dev/null +++ b/test/widgets/repeater_nickname_widgets_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/widgets/repeater_nickname_widgets.dart'; + +void main() { + Widget host(Widget child) => MaterialApp( + home: Scaffold(body: Center(child: child)), + ); + + testWidgets('a display tag with no nickname draws nothing', (tester) async { + await tester.pumpWidget(host(const RepeaterNicknameTag(nickname: null))); + + expect(find.byType(Text), findsNothing); + expect(tester.getSize(find.byType(RepeaterNicknameTag)), Size.zero); + }); + + testWidgets('a display tag shows the nickname', (tester) async { + await tester.pumpWidget(host(const RepeaterNicknameTag(nickname: 'Mell'))); + expect(find.text('Mell'), findsOneWidget); + }); + + testWidgets('the button reads Nickname until one is set, then the nickname', + (tester) async { + var presses = 0; + await tester.pumpWidget(host(Column( + mainAxisSize: MainAxisSize.min, + children: [ + RepeaterNicknameButton(nickname: null, onPressed: () => presses++), + RepeaterNicknameButton(nickname: 'Mell', onPressed: () => presses++), + ], + ))); + + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.byIcon(Icons.label_outline), findsOneWidget); + await tester.tap(find.text('Nickname')); + await tester.tap(find.text('Mell')); + expect(presses, 2); + }); + + testWidgets('the button reads as one named button', (tester) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(host(Column( + mainAxisSize: MainAxisSize.min, + children: [ + RepeaterNicknameButton(nickname: null, onPressed: () {}), + RepeaterNicknameButton(nickname: 'Mell', onPressed: () {}), + ], + ))); + + for (final label in ['Add nickname', 'Edit nickname Mell']) { + expect( + tester.getSemantics(find.bySemanticsLabel(label)), + matchesSemantics( + label: label, + isButton: true, + hasTapAction: true, + ), + ); + } + semantics.dispose(); + }); + + testWidgets('the hex badge shows the hex', (tester) async { + await tester.pumpWidget(host(const RepeaterHexBadge('5007'))); + expect(find.text('5007'), findsOneWidget); + }); +}