From 51d62109201b88f557eb600f86097c9c9850fedf Mon Sep 17 00:00:00 2001 From: zjs81 Date: Fri, 12 Jun 2026 21:04:02 -0700 Subject: [PATCH] Add shared UI components for mesh application - Introduced `mesh_ui.dart` with reusable widgets including SectionHeader, MeshCard, StatusChip, StatTile, AvatarCircle, SignalBars, RouteChip, PulseDot, BottomSheetHeader, ErrorRetryCard, and ListEntrance. - Implemented `path_map_ui.dart` for path map screens, featuring path distance calculations, playback controls, and a summary list of observed paths. - Created `themed_map_tile_layer.dart` for shared cached map tiles with automatic dark-mode treatment. --- lib/helpers/snack_bar_builder.dart | 14 +- lib/l10n/app_en.arb | 95 +- lib/l10n/app_localizations.dart | 228 ++ lib/l10n/app_localizations_bg.dart | 140 + lib/l10n/app_localizations_de.dart | 140 + lib/l10n/app_localizations_en.dart | 140 + lib/l10n/app_localizations_es.dart | 140 + lib/l10n/app_localizations_fr.dart | 140 + lib/l10n/app_localizations_hu.dart | 140 + lib/l10n/app_localizations_it.dart | 140 + lib/l10n/app_localizations_ja.dart | 140 + lib/l10n/app_localizations_ko.dart | 140 + lib/l10n/app_localizations_nl.dart | 140 + lib/l10n/app_localizations_pl.dart | 140 + lib/l10n/app_localizations_pt.dart | 140 + lib/l10n/app_localizations_ru.dart | 140 + lib/l10n/app_localizations_sk.dart | 140 + lib/l10n/app_localizations_sl.dart | 140 + lib/l10n/app_localizations_sv.dart | 140 + lib/l10n/app_localizations_uk.dart | 140 + lib/l10n/app_localizations_zh.dart | 140 + lib/models/display_path.dart | 69 + lib/models/path_playback.dart | 177 ++ lib/screens/app_debug_log_screen.dart | 113 +- lib/screens/app_settings_screen.dart | 2491 ++++++++++------- lib/screens/ble_debug_log_screen.dart | 132 +- lib/screens/channel_chat_screen.dart | 521 ++-- lib/screens/channel_message_path_screen.dart | 1251 +++++++-- lib/screens/channels_screen.dart | 994 ++++--- lib/screens/chat_screen.dart | 413 +-- lib/screens/chrome_required_screen.dart | 155 +- lib/screens/community_qr_scanner_screen.dart | 289 +- lib/screens/companion_radio_stats_screen.dart | 143 +- lib/screens/contacts_screen.dart | 314 ++- lib/screens/discovery_screen.dart | 298 +- lib/screens/line_of_sight_map_screen.dart | 1666 +++++++---- lib/screens/map_cache_screen.dart | 294 +- lib/screens/map_screen.dart | 2354 +++++++++++----- lib/screens/neighbors_screen.dart | 160 +- lib/screens/path_trace_map.dart | 1097 ++++++-- lib/screens/repeater_cli_screen.dart | 1091 +++----- lib/screens/repeater_hub_screen.dart | 457 ++- lib/screens/repeater_settings_screen.dart | 1500 +++++----- lib/screens/repeater_status_screen.dart | 458 ++- lib/screens/scanner_screen.dart | 223 +- lib/screens/settings_screen.dart | 932 +++--- lib/screens/tcp_screen.dart | 156 +- lib/screens/telemetry_screen.dart | 93 +- lib/screens/usb_screen.dart | 222 +- lib/theme/mesh_theme.dart | 258 +- lib/widgets/battery_indicator.dart | 23 +- lib/widgets/device_tile.dart | 112 +- lib/widgets/elements_ui.dart | 83 +- lib/widgets/emoji_picker.dart | 14 +- lib/widgets/empty_state.dart | 112 +- lib/widgets/jump_to_bottom_button.dart | 36 +- lib/widgets/mesh_ui.dart | 643 +++++ lib/widgets/message_status_icon.dart | 12 +- lib/widgets/path_map_ui.dart | 659 +++++ lib/widgets/quick_switch_bar.dart | 27 +- lib/widgets/radio_stats_entry.dart | 13 +- lib/widgets/repeater_login_dialog.dart | 45 +- lib/widgets/room_login_dialog.dart | 41 +- lib/widgets/signal_ui.dart | 11 +- lib/widgets/snr_indicator.dart | 57 +- lib/widgets/telemetry_location_map.dart | 9 +- lib/widgets/themed_map_tile_layer.dart | 60 + lib/widgets/unread_badge.dart | 15 +- lib/widgets/unread_divider.dart | 37 +- linux/flutter/generated_plugins.cmake | 1 - untranslated.json | 699 ++++- windows/flutter/generated_plugins.cmake | 1 - 72 files changed, 16778 insertions(+), 7110 deletions(-) create mode 100644 lib/models/display_path.dart create mode 100644 lib/models/path_playback.dart create mode 100644 lib/widgets/mesh_ui.dart create mode 100644 lib/widgets/path_map_ui.dart create mode 100644 lib/widgets/themed_map_tile_layer.dart diff --git a/lib/helpers/snack_bar_builder.dart b/lib/helpers/snack_bar_builder.dart index d7409b6d..913e783f 100644 --- a/lib/helpers/snack_bar_builder.dart +++ b/lib/helpers/snack_bar_builder.dart @@ -25,7 +25,19 @@ void showDismissibleSnackBar( DismissDirection? dismissDirection, Clip? clipBehavior, }) { - final messenger = ScaffoldMessenger.of(context); + // Callers often reach here after an async gap; the context may already be + // unmounted, or deactivated (popped but not yet disposed) — ancestor + // lookups on a deactivated element throw. Showing nothing is the right + // outcome in both cases. + if (!context.mounted) return; + var isActive = true; + assert(() { + isActive = (context as Element).debugIsActive; + return true; + }()); + if (!isActive) return; + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; messenger.showSnackBar( SnackBar( key: key, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 58613d02..37308ba5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -900,6 +900,17 @@ }, "chat_invalidLink": "Invalid link format", "map_title": "Node Map", + "map_searchHint": "Search node name or ID", + "map_activity": "Activity", + "map_online": "Online", + "map_recent": "Recent", + "map_stale": "Stale", + "map_visible": "Visible", + "map_hidden": "Hidden", + "map_centerOnNode": "Center on node", + "map_details": "Details", + "map_noGps": "No GPS", + "map_noResults": "No matching nodes", "map_lineOfSight": "Line of Sight", "map_losScreenTitle": "Line of Sight", "map_noNodesWithLocation": "No nodes with location data", @@ -2501,5 +2512,87 @@ } }, "pathTrace_legendGpsConfirmed": "GPS confirmed", - "pathTrace_legendInferred": "Inferred position" + "pathTrace_legendInferred": "Inferred position", + "pathMap_viewSingle": "Single", + "pathMap_viewCombined": "Combined", + "pathMap_play": "Play", + "pathMap_pause": "Pause", + "pathMap_replay": "Replay", + "pathMap_stepBack": "Previous hop", + "pathMap_stepForward": "Next hop", + "pathMap_animationOn": "Show packet animation", + "pathMap_animationOff": "Hide packet animation", + "pathMap_hopOf": "Hop {current} of {total}", + "@pathMap_hopOf": { + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "pathMap_observedPaths": "Observed paths: {count}", + "@pathMap_observedPaths": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "pathMap_primary": "Primary", + "pathMap_alternate": "Alt {index}", + "@pathMap_alternate": { + "placeholders": { + "index": { + "type": "int" + } + } + }, + "pathMap_hopCount": "{count, plural, =1{1 hop} other{{count} hops}}", + "@pathMap_hopCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "pathMap_gpsCount": "{confirmed}/{total} GPS", + "@pathMap_gpsCount": { + "placeholders": { + "confirmed": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "pathMap_legendShared": "Shared segment", + "pathMap_legendEstimated": "Estimated segment", + "pathMap_sharedNodeCount": "Used by {count} paths", + "@pathMap_sharedNodeCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "pathMap_partialAnimation": "{count, plural, =1{1 hop has no location — the shown path is partial} other{{count} hops have no location — the shown path is partial}}", + "@pathMap_partialAnimation": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "pathMap_showAllPaths": "Show all", + "pathMap_hidePath": "Hide path", + "pathMap_showPath": "Show path", + "pathMap_collapsePanel": "Collapse panel", + "pathMap_expandPanel": "Expand panel", + "pathMap_noLocation": "No location", + "pathMap_followPacket": "Lock view to packet", + "pathMap_unfollowPacket": "Unlock view from packet" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f55fc02c..8b513be5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3154,6 +3154,72 @@ abstract class AppLocalizations { /// **'Node Map'** String get map_title; + /// No description provided for @map_searchHint. + /// + /// In en, this message translates to: + /// **'Search node name or ID'** + String get map_searchHint; + + /// No description provided for @map_activity. + /// + /// In en, this message translates to: + /// **'Activity'** + String get map_activity; + + /// No description provided for @map_online. + /// + /// In en, this message translates to: + /// **'Online'** + String get map_online; + + /// No description provided for @map_recent. + /// + /// In en, this message translates to: + /// **'Recent'** + String get map_recent; + + /// No description provided for @map_stale. + /// + /// In en, this message translates to: + /// **'Stale'** + String get map_stale; + + /// No description provided for @map_visible. + /// + /// In en, this message translates to: + /// **'Visible'** + String get map_visible; + + /// No description provided for @map_hidden. + /// + /// In en, this message translates to: + /// **'Hidden'** + String get map_hidden; + + /// No description provided for @map_centerOnNode. + /// + /// In en, this message translates to: + /// **'Center on node'** + String get map_centerOnNode; + + /// No description provided for @map_details. + /// + /// In en, this message translates to: + /// **'Details'** + String get map_details; + + /// No description provided for @map_noGps. + /// + /// In en, this message translates to: + /// **'No GPS'** + String get map_noGps; + + /// No description provided for @map_noResults. + /// + /// In en, this message translates to: + /// **'No matching nodes'** + String get map_noResults; + /// No description provided for @map_lineOfSight. /// /// In en, this message translates to: @@ -7701,6 +7767,168 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Inferred position'** String get pathTrace_legendInferred; + + /// No description provided for @pathMap_viewSingle. + /// + /// In en, this message translates to: + /// **'Single'** + String get pathMap_viewSingle; + + /// No description provided for @pathMap_viewCombined. + /// + /// In en, this message translates to: + /// **'Combined'** + String get pathMap_viewCombined; + + /// No description provided for @pathMap_play. + /// + /// In en, this message translates to: + /// **'Play'** + String get pathMap_play; + + /// No description provided for @pathMap_pause. + /// + /// In en, this message translates to: + /// **'Pause'** + String get pathMap_pause; + + /// No description provided for @pathMap_replay. + /// + /// In en, this message translates to: + /// **'Replay'** + String get pathMap_replay; + + /// No description provided for @pathMap_stepBack. + /// + /// In en, this message translates to: + /// **'Previous hop'** + String get pathMap_stepBack; + + /// No description provided for @pathMap_stepForward. + /// + /// In en, this message translates to: + /// **'Next hop'** + String get pathMap_stepForward; + + /// No description provided for @pathMap_animationOn. + /// + /// In en, this message translates to: + /// **'Show packet animation'** + String get pathMap_animationOn; + + /// No description provided for @pathMap_animationOff. + /// + /// In en, this message translates to: + /// **'Hide packet animation'** + String get pathMap_animationOff; + + /// No description provided for @pathMap_hopOf. + /// + /// In en, this message translates to: + /// **'Hop {current} of {total}'** + String pathMap_hopOf(int current, int total); + + /// No description provided for @pathMap_observedPaths. + /// + /// In en, this message translates to: + /// **'Observed paths: {count}'** + String pathMap_observedPaths(int count); + + /// No description provided for @pathMap_primary. + /// + /// In en, this message translates to: + /// **'Primary'** + String get pathMap_primary; + + /// No description provided for @pathMap_alternate. + /// + /// In en, this message translates to: + /// **'Alt {index}'** + String pathMap_alternate(int index); + + /// No description provided for @pathMap_hopCount. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 hop} other{{count} hops}}'** + String pathMap_hopCount(int count); + + /// No description provided for @pathMap_gpsCount. + /// + /// In en, this message translates to: + /// **'{confirmed}/{total} GPS'** + String pathMap_gpsCount(int confirmed, int total); + + /// No description provided for @pathMap_legendShared. + /// + /// In en, this message translates to: + /// **'Shared segment'** + String get pathMap_legendShared; + + /// No description provided for @pathMap_legendEstimated. + /// + /// In en, this message translates to: + /// **'Estimated segment'** + String get pathMap_legendEstimated; + + /// No description provided for @pathMap_sharedNodeCount. + /// + /// In en, this message translates to: + /// **'Used by {count} paths'** + String pathMap_sharedNodeCount(int count); + + /// No description provided for @pathMap_partialAnimation. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 hop has no location — the shown path is partial} other{{count} hops have no location — the shown path is partial}}'** + String pathMap_partialAnimation(int count); + + /// No description provided for @pathMap_showAllPaths. + /// + /// In en, this message translates to: + /// **'Show all'** + String get pathMap_showAllPaths; + + /// No description provided for @pathMap_hidePath. + /// + /// In en, this message translates to: + /// **'Hide path'** + String get pathMap_hidePath; + + /// No description provided for @pathMap_showPath. + /// + /// In en, this message translates to: + /// **'Show path'** + String get pathMap_showPath; + + /// No description provided for @pathMap_collapsePanel. + /// + /// In en, this message translates to: + /// **'Collapse panel'** + String get pathMap_collapsePanel; + + /// No description provided for @pathMap_expandPanel. + /// + /// In en, this message translates to: + /// **'Expand panel'** + String get pathMap_expandPanel; + + /// No description provided for @pathMap_noLocation. + /// + /// In en, this message translates to: + /// **'No location'** + String get pathMap_noLocation; + + /// No description provided for @pathMap_followPacket. + /// + /// In en, this message translates to: + /// **'Lock view to packet'** + String get pathMap_followPacket; + + /// No description provided for @pathMap_unfollowPacket. + /// + /// In en, this message translates to: + /// **'Unlock view from packet'** + String get pathMap_unfollowPacket; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index d22848c3..48d0ea2a 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1738,6 +1738,39 @@ class AppLocalizationsBg extends AppLocalizations { @override String get map_title => 'Карта на възлите'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Линия на видимост'; @@ -4479,4 +4512,111 @@ class AppLocalizationsBg extends AppLocalizations { @override String get pathTrace_legendInferred => 'Извлечена позиция'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 98d25a6d..10cc0baf 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1737,6 +1737,39 @@ class AppLocalizationsDe extends AppLocalizations { @override String get map_title => 'Karte'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Sichtlinie'; @@ -4500,4 +4533,111 @@ class AppLocalizationsDe extends AppLocalizations { @override String get pathTrace_legendInferred => 'Abgeleitete Position'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index eecfbc41..f851b914 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1703,6 +1703,39 @@ class AppLocalizationsEn extends AppLocalizations { @override String get map_title => 'Node Map'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Line of Sight'; @@ -4405,4 +4438,111 @@ class AppLocalizationsEn extends AppLocalizations { @override String get pathTrace_legendInferred => 'Inferred position'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index e0268b4f..c6e8ad2f 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1734,6 +1734,39 @@ class AppLocalizationsEs extends AppLocalizations { @override String get map_title => 'Mapa de Nodos'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Línea de visión'; @@ -4486,4 +4519,111 @@ class AppLocalizationsEs extends AppLocalizations { @override String get pathTrace_legendInferred => 'Posición inferida'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 7bb38a0d..3fd7839d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1741,6 +1741,39 @@ class AppLocalizationsFr extends AppLocalizations { @override String get map_title => 'Carte des nœuds'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Ligne de vue'; @@ -4512,4 +4545,111 @@ class AppLocalizationsFr extends AppLocalizations { @override String get pathTrace_legendInferred => 'Position déduite'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index eef91d09..0a09c402 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -1744,6 +1744,39 @@ class AppLocalizationsHu extends AppLocalizations { @override String get map_title => 'Grafikus ábrázás'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Látási vonal'; @@ -4500,4 +4533,111 @@ class AppLocalizationsHu extends AppLocalizations { @override String get pathTrace_legendInferred => 'Feltehető helyzet'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 89fb77e7..b748de9e 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1738,6 +1738,39 @@ class AppLocalizationsIt extends AppLocalizations { @override String get map_title => 'Mappa Nodi'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Linea di vista'; @@ -4492,4 +4525,111 @@ class AppLocalizationsIt extends AppLocalizations { @override String get pathTrace_legendInferred => 'Posizione dedotta'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index c60816ee..150fef5f 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1661,6 +1661,39 @@ class AppLocalizationsJa extends AppLocalizations { @override String get map_title => 'ノードマップ'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => '視界'; @@ -4257,4 +4290,111 @@ class AppLocalizationsJa extends AppLocalizations { @override String get pathTrace_legendInferred => '推測される位置'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 812c2f94..783990cc 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1656,6 +1656,39 @@ class AppLocalizationsKo extends AppLocalizations { @override String get map_title => '노드 매핑'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => '시야'; @@ -4254,4 +4287,111 @@ class AppLocalizationsKo extends AppLocalizations { @override String get pathTrace_legendInferred => '추론된 위치'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 94d5e1e3..087a0204 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1721,6 +1721,39 @@ class AppLocalizationsNl extends AppLocalizations { @override String get map_title => 'Kaart van de knopen'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Zichtlijn'; @@ -4463,4 +4496,111 @@ class AppLocalizationsNl extends AppLocalizations { @override String get pathTrace_legendInferred => 'Afgeleide positie'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index b95dcbfc..ae102624 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1750,6 +1750,39 @@ class AppLocalizationsPl extends AppLocalizations { @override String get map_title => 'Mapa węzłów'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Linia wzroku'; @@ -4501,4 +4534,111 @@ class AppLocalizationsPl extends AppLocalizations { @override String get pathTrace_legendInferred => 'Wywnioskowana pozycja'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 09a4fa79..55dccf17 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1734,6 +1734,39 @@ class AppLocalizationsPt extends AppLocalizations { @override String get map_title => 'Mapa de Nós'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Linha de visão'; @@ -4480,4 +4513,111 @@ class AppLocalizationsPt extends AppLocalizations { @override String get pathTrace_legendInferred => 'Posição inferida'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 6503c5c1..a66e99d4 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1739,6 +1739,39 @@ class AppLocalizationsRu extends AppLocalizations { @override String get map_title => 'Карта нод'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Линия видимости'; @@ -4496,4 +4529,111 @@ class AppLocalizationsRu extends AppLocalizations { @override String get pathTrace_legendInferred => 'Выведенная позиция'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 3bb7ac5f..349c4c6e 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1726,6 +1726,39 @@ class AppLocalizationsSk extends AppLocalizations { @override String get map_title => 'Mapa uzlov'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Úroveň výhľadu'; @@ -4462,4 +4495,111 @@ class AppLocalizationsSk extends AppLocalizations { @override String get pathTrace_legendInferred => 'Odvodená poloha'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index e1efed05..f79b0d2e 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1719,6 +1719,39 @@ class AppLocalizationsSl extends AppLocalizations { @override String get map_title => 'Mapa omrežja'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Linija vida'; @@ -4461,4 +4494,111 @@ class AppLocalizationsSl extends AppLocalizations { @override String get pathTrace_legendInferred => 'Izpeljana lokacija'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index a95e8903..8a354354 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1712,6 +1712,39 @@ class AppLocalizationsSv extends AppLocalizations { @override String get map_title => 'Nodkarta'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Synlinje'; @@ -4435,4 +4468,111 @@ class AppLocalizationsSv extends AppLocalizations { @override String get pathTrace_legendInferred => 'Antagen position'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 989acdb8..8379a505 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1732,6 +1732,39 @@ class AppLocalizationsUk extends AppLocalizations { @override String get map_title => 'Карта вузлів'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => 'Пряма видимість'; @@ -4495,4 +4528,111 @@ class AppLocalizationsUk extends AppLocalizations { @override String get pathTrace_legendInferred => 'Висновок щодо положення'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index bf4131b8..d2643bd9 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1634,6 +1634,39 @@ class AppLocalizationsZh extends AppLocalizations { @override String get map_title => '节点地图'; + @override + String get map_searchHint => 'Search node name or ID'; + + @override + String get map_activity => 'Activity'; + + @override + String get map_online => 'Online'; + + @override + String get map_recent => 'Recent'; + + @override + String get map_stale => 'Stale'; + + @override + String get map_visible => 'Visible'; + + @override + String get map_hidden => 'Hidden'; + + @override + String get map_centerOnNode => 'Center on node'; + + @override + String get map_details => 'Details'; + + @override + String get map_noGps => 'No GPS'; + + @override + String get map_noResults => 'No matching nodes'; + @override String get map_lineOfSight => '视线'; @@ -4145,4 +4178,111 @@ class AppLocalizationsZh extends AppLocalizations { @override String get pathTrace_legendInferred => '推测的位置'; + + @override + String get pathMap_viewSingle => 'Single'; + + @override + String get pathMap_viewCombined => 'Combined'; + + @override + String get pathMap_play => 'Play'; + + @override + String get pathMap_pause => 'Pause'; + + @override + String get pathMap_replay => 'Replay'; + + @override + String get pathMap_stepBack => 'Previous hop'; + + @override + String get pathMap_stepForward => 'Next hop'; + + @override + String get pathMap_animationOn => 'Show packet animation'; + + @override + String get pathMap_animationOff => 'Hide packet animation'; + + @override + String pathMap_hopOf(int current, int total) { + return 'Hop $current of $total'; + } + + @override + String pathMap_observedPaths(int count) { + return 'Observed paths: $count'; + } + + @override + String get pathMap_primary => 'Primary'; + + @override + String pathMap_alternate(int index) { + return 'Alt $index'; + } + + @override + String pathMap_hopCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops', + one: '1 hop', + ); + return '$_temp0'; + } + + @override + String pathMap_gpsCount(int confirmed, int total) { + return '$confirmed/$total GPS'; + } + + @override + String get pathMap_legendShared => 'Shared segment'; + + @override + String get pathMap_legendEstimated => 'Estimated segment'; + + @override + String pathMap_sharedNodeCount(int count) { + return 'Used by $count paths'; + } + + @override + String pathMap_partialAnimation(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hops have no location — the shown path is partial', + one: '1 hop has no location — the shown path is partial', + ); + return '$_temp0'; + } + + @override + String get pathMap_showAllPaths => 'Show all'; + + @override + String get pathMap_hidePath => 'Hide path'; + + @override + String get pathMap_showPath => 'Show path'; + + @override + String get pathMap_collapsePanel => 'Collapse panel'; + + @override + String get pathMap_expandPanel => 'Expand panel'; + + @override + String get pathMap_noLocation => 'No location'; + + @override + String get pathMap_followPacket => 'Lock view to packet'; + + @override + String get pathMap_unfollowPacket => 'Unlock view from packet'; } diff --git a/lib/models/display_path.dart b/lib/models/display_path.dart new file mode 100644 index 00000000..71f8ed53 --- /dev/null +++ b/lib/models/display_path.dart @@ -0,0 +1,69 @@ +import 'dart:ui'; + +import 'package:latlong2/latlong.dart'; + +import 'path_history.dart'; + +/// One observed route rendered on the path map — the live traced path +/// (primary) or an alternate from the contact's path history — resolved to +/// map coordinates with per-hop confidence flags. +class DisplayPath { + final String id; + final String label; + final Color color; + final bool isPrimary; + + /// Outbound hop bytes, including hops that could not be placed on the map. + final List hopBytes; + + /// Resolved map points: self, each locatable hop, then the target when its + /// position is known. Hops with no position are skipped here but still + /// counted in [unresolvedHops]. + final List points; + + /// Display name for each entry of [points]. + final List pointLabels; + + /// Whether each entry of [points] is a GPS-grade position (vs inferred). + final List pointConfirmed; + + /// Per segment (length points-1): true when either endpoint is inferred or + /// unlocatable hops were skipped in between — rendered dashed. + final List segmentEstimated; + + /// Per segment: the transmission ordinal of the segment's destination, + /// used to highlight the matching hop-list row during animation. + final List rowForSegment; + + /// Total transmissions on the full route (including unlocatable hops). + final int totalTransmissions; + + /// True when the route ends with a chat-target endpoint row. + final bool hasTargetEndpoint; + + final int gpsConfirmedHops; + final int unresolvedHops; + final double distanceMeters; + + /// History metadata; null for the live traced (primary) path. + final PathRecord? record; + + const DisplayPath({ + required this.id, + required this.label, + required this.color, + required this.isPrimary, + required this.hopBytes, + required this.points, + required this.pointLabels, + required this.pointConfirmed, + required this.segmentEstimated, + required this.rowForSegment, + required this.totalTransmissions, + required this.hasTargetEndpoint, + required this.gpsConfirmedHops, + required this.unresolvedHops, + required this.distanceMeters, + this.record, + }); +} diff --git a/lib/models/path_playback.dart b/lib/models/path_playback.dart new file mode 100644 index 00000000..89fdff0b --- /dev/null +++ b/lib/models/path_playback.dart @@ -0,0 +1,177 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:latlong2/latlong.dart'; + +/// Timeline state for the packet-flow animation on the path map. +/// +/// The packet travels each segment over [segmentMs] (scaled by [speed]), +/// then dwells at the reached hop for [dwellMs] so the hop visibly lights up. +/// Overlay layers listen to this controller directly; [activeSegment] only +/// fires when the segment index changes so list highlights rebuild cheaply. +class PathPlaybackController extends ChangeNotifier { + static const double segmentMs = 1100; + static const double dwellMs = 380; + static const List speedSteps = [0.5, 1.0, 2.0]; + + late final Ticker _ticker; + List _points = const []; + double _timelineMs = 0; + Duration _lastTick = Duration.zero; + bool _playing = false; + bool _started = false; + double _speed = 1.0; + + /// Segment currently being traveled (clamped to the last segment), or -1 + /// while the animation has not been started — listeners use this for + /// hop-list highlighting without rebuilding every tick. + final ValueNotifier activeSegment = ValueNotifier(-1); + + PathPlaybackController(TickerProvider vsync) { + _ticker = vsync.createTicker(_onTick); + } + + List get points => _points; + bool get hasPath => _points.length >= 2; + int get segmentCount => hasPath ? _points.length - 1 : 0; + bool get playing => _playing; + double get speed => _speed; + + /// True once the user has started or stepped the animation; the packet + /// overlay renders only in this state. + bool get started => _started; + + double get _slotMs => segmentMs + dwellMs; + double get _totalMs => segmentCount * _slotMs; + bool get isComplete => hasPath && _timelineMs >= _totalMs; + + int get currentSegment { + if (!hasPath) return 0; + return (_timelineMs / _slotMs).floor().clamp(0, segmentCount - 1); + } + + /// Travel progress through [currentSegment]; 1.0 while dwelling at its end. + double get segmentProgress { + if (!hasPath) return 0; + final within = _timelineMs - currentSegment * _slotMs; + return (within / segmentMs).clamp(0.0, 1.0); + } + + /// Dwell progress (0..1) at the reached hop, or null while traveling. + double? get dwellProgress { + if (!hasPath || isComplete) return null; + final within = _timelineMs - currentSegment * _slotMs; + if (within < segmentMs) return null; + return ((within - segmentMs) / dwellMs).clamp(0.0, 1.0); + } + + /// Index of the point the packet has most recently reached. + int get reachedPointIndex { + if (!hasPath) return 0; + if (isComplete) return _points.length - 1; + return segmentProgress >= 1.0 ? currentSegment + 1 : currentSegment; + } + + LatLng get position { + if (!hasPath) return const LatLng(0, 0); + final seg = currentSegment; + final a = _points[seg]; + final b = _points[seg + 1]; + final t = segmentProgress; + return LatLng( + a.latitude + (b.latitude - a.latitude) * t, + a.longitude + (b.longitude - a.longitude) * t, + ); + } + + /// Replaces the path and resets the animation to the start. + void setPath(List points) { + _ticker.stop(); + _points = List.unmodifiable(points); + _timelineMs = 0; + _playing = false; + _started = false; + activeSegment.value = -1; + notifyListeners(); + } + + void play() { + if (!hasPath) return; + if (isComplete) _timelineMs = 0; + _started = true; + _playing = true; + activeSegment.value = currentSegment; + if (!_ticker.isActive) { + _lastTick = Duration.zero; + _ticker.start(); + } + notifyListeners(); + } + + void pause() { + _ticker.stop(); + _playing = false; + notifyListeners(); + } + + void togglePlay() => _playing ? pause() : play(); + + void replay() { + if (!hasPath) return; + _timelineMs = 0; + activeSegment.value = 0; + play(); + } + + /// Stops playback and hides the packet overlay. + void stop() { + _ticker.stop(); + _playing = false; + _started = false; + _timelineMs = 0; + activeSegment.value = -1; + notifyListeners(); + } + + void stepForward() => _jumpToPoint(reachedPointIndex + 1); + + void stepBack() => _jumpToPoint(reachedPointIndex - 1); + + void cycleSpeed() { + final index = speedSteps.indexOf(_speed); + _speed = speedSteps[(index + 1) % speedSteps.length]; + notifyListeners(); + } + + void _jumpToPoint(int index) { + if (!hasPath) return; + _ticker.stop(); + _playing = false; + _started = true; + final clamped = index.clamp(0, _points.length - 1); + // Land at the start of the dwell window so the hop pulse plays. + _timelineMs = clamped == 0 ? 0 : (clamped - 1) * _slotMs + segmentMs; + activeSegment.value = currentSegment; + notifyListeners(); + } + + void _onTick(Duration elapsed) { + final dtMs = (elapsed - _lastTick).inMicroseconds / 1000.0; + _lastTick = elapsed; + _timelineMs = (_timelineMs + dtMs * _speed).clamp(0.0, _totalMs); + if (_timelineMs >= _totalMs) { + _ticker.stop(); + _playing = false; + } + if (activeSegment.value != currentSegment) { + activeSegment.value = currentSegment; + } + notifyListeners(); + } + + @override + void dispose() { + _ticker.dispose(); + activeSegment.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/app_debug_log_screen.dart b/lib/screens/app_debug_log_screen.dart index 522a2c5c..4b00c70c 100644 --- a/lib/screens/app_debug_log_screen.dart +++ b/lib/screens/app_debug_log_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../services/app_debug_log_service.dart'; +import '../theme/mesh_theme.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../helpers/snack_bar_builder.dart'; @@ -58,27 +59,57 @@ class AppDebugLogScreen extends StatelessWidget { child: hasEntries ? ListView.separated( itemCount: entries.length, - separatorBuilder: (_, _) => const Divider(height: 1), + separatorBuilder: (_, _) => + const Divider(height: 1, color: MeshPalette.line), itemBuilder: (context, index) { final entry = entries[index]; - return ListTile( - dense: true, - leading: _buildLevelIcon(context, entry.level), - title: Text( - '[${entry.tag}] ${entry.message}', - style: const TextStyle( - fontSize: 12, - fontFamily: 'monospace', - ), + return Container( + color: MeshPalette.bg, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, ), - subtitle: Text( - entry.formattedTime, - style: TextStyle( - fontSize: 10, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLevelIcon(context, entry.level), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan( + text: '[${entry.tag}] ', + style: MeshTheme.mono( + fontSize: 11.5, + color: _levelColor(entry.level), + ), + ), + TextSpan( + text: entry.message, + style: MeshTheme.mono( + fontSize: 11.5, + color: MeshPalette.ink2, + ), + ), + ], + ), + ), + const SizedBox(height: 2), + Text( + entry.formattedTime, + style: MeshTheme.mono( + fontSize: 9.5, + color: MeshPalette.ink4, + ), + ), + ], + ), + ), + ], ), ); }, @@ -87,29 +118,25 @@ class AppDebugLogScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.bug_report_outlined, size: 64, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: MeshPalette.ink3, ), const SizedBox(height: 16), Text( context.l10n.debugLog_noEntries, - style: TextStyle( + style: const TextStyle( fontSize: 16, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + color: MeshPalette.ink3, ), ), const SizedBox(height: 8), Text( context.l10n.debugLog_enableInSettings, - style: TextStyle( + style: const TextStyle( fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + color: MeshPalette.ink3, ), ), ], @@ -121,19 +148,37 @@ class AppDebugLogScreen extends StatelessWidget { ); } - Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) { - final colorScheme = Theme.of(context).colorScheme; + Color _levelColor(AppDebugLogLevel level) { switch (level) { case AppDebugLogLevel.info: - return Icon(Icons.info_outline, size: 18, color: colorScheme.primary); + return MeshPalette.blue; case AppDebugLogLevel.warning: - return Icon( + return MeshPalette.warn; + case AppDebugLogLevel.error: + return MeshPalette.alert; + } + } + + Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) { + switch (level) { + case AppDebugLogLevel.info: + return const Icon( + Icons.info_outline, + size: 18, + color: MeshPalette.blue, + ); + case AppDebugLogLevel.warning: + return const Icon( Icons.warning_amber_outlined, size: 18, - color: colorScheme.tertiary, + color: MeshPalette.warn, ); case AppDebugLogLevel.error: - return Icon(Icons.error_outline, size: 18, color: colorScheme.error); + return const Icon( + Icons.error_outline, + size: 18, + color: MeshPalette.alert, + ); } } } diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index d1ccfd09..3d3af8b9 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -10,7 +10,9 @@ import '../models/translation_support.dart'; import '../services/app_settings_service.dart'; import '../services/notification_service.dart'; import '../services/translation_service.dart'; +import '../theme/mesh_theme.dart'; import '../widgets/adaptive_app_bar_title.dart'; +import '../widgets/mesh_ui.dart'; import '../widgets/sync_progress_overlay.dart'; import '../helpers/snack_bar_builder.dart'; import 'map_cache_screen.dart'; @@ -28,73 +30,123 @@ class AppSettingsScreen extends StatelessWidget { ), body: SafeArea( top: false, - child: - Consumer3< - AppSettingsService, - MeshCoreConnector, - TranslationService - >( - builder: - ( + child: Consumer3( + builder: ( + context, + settingsService, + connector, + translationService, + child, + ) { + return ListView( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 24), + children: [ + // APPEARANCE + SectionHeader(context.l10n.appSettings_appearance), + MeshCard( + padding: EdgeInsets.zero, + child: _buildAppearanceContent(context, settingsService), + ), + + // NOTIFICATIONS + SectionHeader(context.l10n.appSettings_notifications), + MeshCard( + padding: EdgeInsets.zero, + child: _buildNotificationsContent(context, settingsService), + ), + + // MESSAGING + SectionHeader(context.l10n.appSettings_messaging), + MeshCard( + padding: EdgeInsets.zero, + child: _buildMessagingContent(context, settingsService), + ), + + // BATTERY + SectionHeader(context.l10n.appSettings_battery), + MeshCard( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: _buildBatteryContent( context, settingsService, connector, - translationService, - child, - ) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildAppearanceCard(context, settingsService), - const SizedBox(height: 16), - _buildNotificationsCard(context, settingsService), - const SizedBox(height: 16), - _buildMessagingCard(context, settingsService), - const SizedBox(height: 16), - if (!kIsWeb) ...[ - _buildTranslationCard( - context, - settingsService, - translationService, - ), - const SizedBox(height: 16), - ], - _buildBatteryCard(context, settingsService, connector), - const SizedBox(height: 16), - _buildMapSettingsCard(context, settingsService), - const SizedBox(height: 16), - _buildCyr2LatCard(context, settingsService), - const SizedBox(height: 16), - _buildDebugCard(context, settingsService), - ], - ); - }, - ), + ), + ), + + // MAP + SectionHeader(context.l10n.appSettings_mapDisplay), + MeshCard( + padding: EdgeInsets.zero, + child: _buildMapContent(context, settingsService), + ), + + // TRANSLATION (non-web only) + if (!kIsWeb) ...[ + SectionHeader(context.l10n.translation_title), + MeshCard( + padding: EdgeInsets.zero, + child: _buildTranslationContent( + context, + settingsService, + translationService, + ), + ), + ], + + // CYR2LAT + SectionHeader(context.l10n.channels_cyr2latSettingsHeading), + MeshCard( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: _buildCyr2LatContent(context, settingsService), + ), + + // DEBUG + SectionHeader(context.l10n.appSettings_debugCard), + MeshCard( + padding: EdgeInsets.zero, + child: _buildDebugContent(context, settingsService), + ), + ], + ); + }, + ), ), ); } - Widget _buildAppearanceCard( + Widget _buildAppearanceContent( BuildContext context, AppSettingsService settingsService, ) { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - context.l10n.appSettings_appearance, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - ListTile( - leading: const Icon(Icons.brightness_6_outlined), - title: Text(context.l10n.appSettings_theme), - subtitle: Padding( - padding: const EdgeInsets.only(top: 8), - child: SegmentedButton( + final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.brightness_6_outlined, + size: 20, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Text( + context.l10n.appSettings_theme, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 10), + SegmentedButton( segments: [ ButtonSegment( value: 'system', @@ -114,713 +166,1016 @@ class AppSettingsScreen extends StatelessWidget { settingsService.setThemeMode(selection.first); }, ), + ], + ), + ), + const Divider(height: 1, indent: 16), + InkWell( + onTap: () => _showLanguageSheet(context, settingsService), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.language_outlined, + size: 20, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.appSettings_language, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + _languageLabel( + context, + settingsService.settings.languageOverride, + ), + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: scheme.onSurfaceVariant, + size: 16, + ), + ], ), ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.language_outlined), - title: Text(context.l10n.appSettings_language), - subtitle: Text( - _languageLabel( - context, - settingsService.settings.languageOverride, - ), - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => _showLanguageDialog(context, settingsService), - ), - ], - ), + ), + ], ); } - Widget _buildNotificationsCard( + Widget _buildNotificationsContent( BuildContext context, AppSettingsService settingsService, ) { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - context.l10n.appSettings_notifications, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + final notifEnabled = settingsService.settings.notificationsEnabled; + return Column( + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: const Icon(Icons.notifications_outlined, size: 20), + title: Text(context.l10n.appSettings_enableNotifications), + subtitle: Text(context.l10n.appSettings_enableNotificationsSubtitle), + value: settingsService.settings.notificationsEnabled, + onChanged: (value) async { + if (value) { + final granted = await NotificationService().requestPermissions(); + if (!granted) { + if (context.mounted) { + showDismissibleSnackBar( + context, + content: Text( + context.l10n.appSettings_notificationPermissionDenied, + ), + duration: const Duration(seconds: 2), + ); + } + return; + } + } + await settingsService.setNotificationsEnabled(value); + if (context.mounted) { + showDismissibleSnackBar( + context, + content: Text( + value + ? context.l10n.appSettings_notificationsEnabled + : context.l10n.appSettings_notificationsDisabled, + ), + duration: const Duration(seconds: 2), + ); + } + }, + ), + const Divider(height: 1, indent: 16), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: Icon( + Icons.message_outlined, + size: 20, + color: notifEnabled ? null : Theme.of(context).disabledColor, + ), + title: Text( + context.l10n.appSettings_messageNotifications, + style: TextStyle( + color: notifEnabled ? null : Theme.of(context).disabledColor, ), ), - SwitchListTile( - secondary: const Icon(Icons.notifications_outlined), - title: Text(context.l10n.appSettings_enableNotifications), - subtitle: Text( - context.l10n.appSettings_enableNotificationsSubtitle, + subtitle: Text( + context.l10n.appSettings_messageNotificationsSubtitle, + style: TextStyle( + color: notifEnabled ? null : Theme.of(context).disabledColor, ), - value: settingsService.settings.notificationsEnabled, - onChanged: (value) async { - if (value) { - // Request permission when enabling - final granted = await NotificationService() - .requestPermissions(); - if (!granted) { - if (context.mounted) { - showDismissibleSnackBar( - context, - content: Text( - context.l10n.appSettings_notificationPermissionDenied, + ), + value: settingsService.settings.notifyOnNewMessage, + onChanged: notifEnabled + ? (value) => settingsService.setNotifyOnNewMessage(value) + : null, + ), + const Divider(height: 1, indent: 16), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: Icon( + Icons.forum_outlined, + size: 20, + color: notifEnabled ? null : Theme.of(context).disabledColor, + ), + title: Text( + context.l10n.appSettings_channelMessageNotifications, + style: TextStyle( + color: notifEnabled ? null : Theme.of(context).disabledColor, + ), + ), + subtitle: Text( + context.l10n.appSettings_channelMessageNotificationsSubtitle, + style: TextStyle( + color: notifEnabled ? null : Theme.of(context).disabledColor, + ), + ), + value: settingsService.settings.notifyOnNewChannelMessage, + onChanged: notifEnabled + ? (value) => + settingsService.setNotifyOnNewChannelMessage(value) + : null, + ), + const Divider(height: 1, indent: 16), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: Icon( + Icons.cell_tower, + size: 20, + color: notifEnabled ? null : Theme.of(context).disabledColor, + ), + title: Text( + context.l10n.appSettings_advertisementNotifications, + style: TextStyle( + color: notifEnabled ? null : Theme.of(context).disabledColor, + ), + ), + subtitle: Text( + context.l10n.appSettings_advertisementNotificationsSubtitle, + style: TextStyle( + color: notifEnabled ? null : Theme.of(context).disabledColor, + ), + ), + value: settingsService.settings.notifyOnNewAdvert, + onChanged: notifEnabled + ? (value) => settingsService.setNotifyOnNewAdvert(value) + : null, + ), + ], + ); + } + + Widget _buildMessagingContent( + BuildContext context, + AppSettingsService settingsService, + ) { + final autoRouteEnabled = settingsService.settings.autoRouteRotationEnabled; + return Column( + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: const Icon(Icons.refresh_outlined, size: 20), + title: Text(context.l10n.appSettings_clearPathOnMaxRetry), + subtitle: Text( + context.l10n.appSettings_clearPathOnMaxRetrySubtitle, + ), + value: settingsService.settings.clearPathOnMaxRetry, + onChanged: (value) { + settingsService.setClearPathOnMaxRetry(value); + showDismissibleSnackBar( + context, + content: Text( + value + ? context.l10n.appSettings_pathsWillBeCleared + : context.l10n.appSettings_pathsWillNotBeCleared, + ), + duration: const Duration(seconds: 2), + ); + }, + ), + const Divider(height: 1, indent: 16), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: const Icon(Icons.vertical_align_top, size: 20), + title: Text(context.l10n.appSettings_jumpToOldestUnread), + subtitle: Text(context.l10n.appSettings_jumpToOldestUnreadSubtitle), + value: settingsService.settings.jumpToOldestUnread, + onChanged: settingsService.setJumpToOldestUnread, + ), + const Divider(height: 1, indent: 16), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: const Icon(Icons.alt_route, size: 20), + title: Text(context.l10n.appSettings_autoRouteRotation), + subtitle: Text(context.l10n.appSettings_autoRouteRotationSubtitle), + value: autoRouteEnabled, + onChanged: (value) { + settingsService.setAutoRouteRotationEnabled(value); + showDismissibleSnackBar( + context, + content: Text( + value + ? context.l10n.appSettings_autoRouteRotationEnabled + : context.l10n.appSettings_autoRouteRotationDisabled, + ), + duration: const Duration(seconds: 2), + ); + }, + ), + // AnimatedSize sub-options for auto-route rotation + AnimatedSize( + duration: const Duration(milliseconds: 200), + alignment: Alignment.topCenter, + child: autoRouteEnabled + ? Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + padding: const EdgeInsets.only(left: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1), + ListTile( + title: Text(context.l10n.appSettings_maxRouteWeight), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.appSettings_maxRouteWeightSubtitle, + ), + Slider( + value: settingsService.settings.maxRouteWeight, + min: 1, + max: 10, + divisions: 9, + label: settingsService.settings.maxRouteWeight + .round() + .toString(), + onChanged: (value) => + settingsService.setMaxRouteWeight(value), + ), + ], + ), ), - duration: const Duration(seconds: 2), + const Divider(height: 1), + ListTile( + title: Text( + context.l10n.appSettings_initialRouteWeight, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context + .l10n + .appSettings_initialRouteWeightSubtitle, + ), + Slider( + value: + settingsService.settings.initialRouteWeight, + min: 0.5, + max: 5.0, + divisions: 9, + label: settingsService + .settings + .initialRouteWeight + .toStringAsFixed(1), + onChanged: (value) => + settingsService.setInitialRouteWeight(value), + ), + ], + ), + ), + const Divider(height: 1), + ListTile( + title: Text( + context + .l10n + .appSettings_routeWeightSuccessIncrement, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context + .l10n + .appSettings_routeWeightSuccessIncrementSubtitle, + ), + Slider( + value: settingsService + .settings + .routeWeightSuccessIncrement, + min: 0.1, + max: 2.0, + divisions: 19, + label: settingsService + .settings + .routeWeightSuccessIncrement + .toStringAsFixed(1), + onChanged: (value) => settingsService + .setRouteWeightSuccessIncrement(value), + ), + ], + ), + ), + const Divider(height: 1), + ListTile( + title: Text( + context + .l10n + .appSettings_routeWeightFailureDecrement, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context + .l10n + .appSettings_routeWeightFailureDecrementSubtitle, + ), + Slider( + value: settingsService + .settings + .routeWeightFailureDecrement, + min: 0.1, + max: 2.0, + divisions: 19, + label: settingsService + .settings + .routeWeightFailureDecrement + .toStringAsFixed(1), + onChanged: (value) => settingsService + .setRouteWeightFailureDecrement(value), + ), + ], + ), + ), + const Divider(height: 1), + ListTile( + title: Text( + context.l10n.appSettings_maxMessageRetries, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context + .l10n + .appSettings_maxMessageRetriesSubtitle, + ), + Slider( + value: settingsService + .settings + .maxMessageRetries + .toDouble(), + min: 2, + max: 10, + divisions: 8, + label: settingsService + .settings + .maxMessageRetries + .toString(), + onChanged: (value) => settingsService + .setMaxMessageRetries(value.toInt()), + ), + ], + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + const Divider(height: 1, indent: 16), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: const Icon(Icons.location_searching, size: 20), + title: Text(context.l10n.appSettings_enableMessageTracing), + subtitle: Text( + context.l10n.appSettings_enableMessageTracingSubtitle, + ), + value: settingsService.settings.enableMessageTracing, + onChanged: (value) { + settingsService.setEnableMessageTracing(value); + }, + ), + ], + ); + } + + Widget _buildBatteryContent( + BuildContext context, + AppSettingsService settingsService, + MeshCoreConnector connector, + ) { + final deviceId = connector.deviceId; + final isConnected = connector.isConnected && deviceId != null; + final selection = isConnected + ? settingsService.batteryChemistryForDevice(deviceId) + : 'nmc'; + final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 4), + child: Row( + children: [ + Icon( + Icons.battery_full, + size: 20, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.appSettings_batteryChemistry, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + isConnected + ? context.l10n.appSettings_batteryChemistryPerDevice( + connector.deviceDisplayName, + ) + : context + .l10n + .appSettings_batteryChemistryConnectFirst, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: selection, + isExpanded: true, + decoration: const InputDecoration( + border: UnderlineInputBorder(), + isDense: true, + ), + onChanged: isConnected + ? (value) { + if (value != null) { + settingsService.setBatteryChemistryForDevice( + deviceId, + value, ); } - return; } - } - - await settingsService.setNotificationsEnabled(value); - if (context.mounted) { - showDismissibleSnackBar( - context, - content: Text( - value - ? context.l10n.appSettings_notificationsEnabled - : context.l10n.appSettings_notificationsDisabled, - ), - duration: const Duration(seconds: 2), - ); - } - }, - ), - const Divider(height: 1), - SwitchListTile( - secondary: Icon( - Icons.message_outlined, - color: settingsService.settings.notificationsEnabled - ? null - : Theme.of(context).disabledColor, + : null, + items: [ + DropdownMenuItem( + value: 'nmc', + child: Text(context.l10n.appSettings_batteryNmc), ), - title: Text( - context.l10n.appSettings_messageNotifications, - style: TextStyle( - color: settingsService.settings.notificationsEnabled - ? null - : Theme.of(context).disabledColor, - ), + DropdownMenuItem( + value: 'lifepo4', + child: Text(context.l10n.appSettings_batteryLifepo4), ), - subtitle: Text( - context.l10n.appSettings_messageNotificationsSubtitle, - style: TextStyle( - color: settingsService.settings.notificationsEnabled - ? null - : Theme.of(context).disabledColor, - ), + DropdownMenuItem( + value: 'lipo', + child: Text(context.l10n.appSettings_batteryLipo), ), - value: settingsService.settings.notifyOnNewMessage, - onChanged: settingsService.settings.notificationsEnabled - ? (value) { - settingsService.setNotifyOnNewMessage(value); - } - : null, - ), - const Divider(height: 1), - SwitchListTile( - secondary: Icon( - Icons.forum_outlined, - color: settingsService.settings.notificationsEnabled - ? null - : Theme.of(context).disabledColor, - ), - title: Text( - context.l10n.appSettings_channelMessageNotifications, - style: TextStyle( - color: settingsService.settings.notificationsEnabled - ? null - : Theme.of(context).disabledColor, - ), - ), - subtitle: Text( - context.l10n.appSettings_channelMessageNotificationsSubtitle, - style: TextStyle( - color: settingsService.settings.notificationsEnabled - ? null - : Theme.of(context).disabledColor, - ), - ), - value: settingsService.settings.notifyOnNewChannelMessage, - onChanged: settingsService.settings.notificationsEnabled - ? (value) { - settingsService.setNotifyOnNewChannelMessage(value); - } - : null, - ), - const Divider(height: 1), - SwitchListTile( - secondary: Icon( - Icons.cell_tower, - color: settingsService.settings.notificationsEnabled - ? null - : Theme.of(context).disabledColor, - ), - title: Text( - context.l10n.appSettings_advertisementNotifications, - style: TextStyle( - color: settingsService.settings.notificationsEnabled - ? null - : Theme.of(context).disabledColor, - ), - ), - subtitle: Text( - context.l10n.appSettings_advertisementNotificationsSubtitle, - style: TextStyle( - color: settingsService.settings.notificationsEnabled - ? null - : Theme.of(context).disabledColor, - ), - ), - value: settingsService.settings.notifyOnNewAdvert, - onChanged: settingsService.settings.notificationsEnabled - ? (value) { - settingsService.setNotifyOnNewAdvert(value); - } - : null, - ), - ], - ), + ], + ), + ], ); } - Widget _buildMessagingCard( + Widget _buildMapContent( BuildContext context, AppSettingsService settingsService, ) { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - context.l10n.appSettings_messaging, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), + final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Column( + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, ), - SwitchListTile( - secondary: const Icon(Icons.refresh_outlined), - title: Text(context.l10n.appSettings_clearPathOnMaxRetry), - subtitle: Text( - context.l10n.appSettings_clearPathOnMaxRetrySubtitle, - ), - value: settingsService.settings.clearPathOnMaxRetry, - onChanged: (value) { - settingsService.setClearPathOnMaxRetry(value); - showDismissibleSnackBar( - context, - content: Text( - value - ? context.l10n.appSettings_pathsWillBeCleared - : context.l10n.appSettings_pathsWillNotBeCleared, + secondary: const Icon(Icons.router_outlined, size: 20), + title: Text(context.l10n.appSettings_showRepeaters), + subtitle: Text(context.l10n.appSettings_showRepeatersSubtitle), + value: settingsService.settings.mapShowRepeaters, + onChanged: (value) => settingsService.setMapShowRepeaters(value), + ), + const Divider(height: 1, indent: 16), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: const Icon(Icons.chat_outlined, size: 20), + title: Text(context.l10n.appSettings_showChatNodes), + subtitle: Text(context.l10n.appSettings_showChatNodesSubtitle), + value: settingsService.settings.mapShowChatNodes, + onChanged: (value) => settingsService.setMapShowChatNodes(value), + ), + const Divider(height: 1, indent: 16), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: const Icon(Icons.people_outline, size: 20), + title: Text(context.l10n.appSettings_showOtherNodes), + subtitle: Text(context.l10n.appSettings_showOtherNodesSubtitle), + value: settingsService.settings.mapShowOtherNodes, + onChanged: (value) => settingsService.setMapShowOtherNodes(value), + ), + const Divider(height: 1, indent: 16), + InkWell( + onTap: () => _showTimeFilterSheet(context, settingsService), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.timer_outlined, + size: 20, + color: scheme.onSurfaceVariant, ), - duration: const Duration(seconds: 2), - ); - }, - ), - const Divider(height: 1), - SwitchListTile( - secondary: const Icon(Icons.vertical_align_top), - title: Text(context.l10n.appSettings_jumpToOldestUnread), - subtitle: Text(context.l10n.appSettings_jumpToOldestUnreadSubtitle), - value: settingsService.settings.jumpToOldestUnread, - onChanged: settingsService.setJumpToOldestUnread, - ), - const Divider(height: 1), - SwitchListTile( - secondary: const Icon(Icons.alt_route), - title: Text(context.l10n.appSettings_autoRouteRotation), - subtitle: Text(context.l10n.appSettings_autoRouteRotationSubtitle), - value: settingsService.settings.autoRouteRotationEnabled, - onChanged: (value) { - settingsService.setAutoRouteRotationEnabled(value); - showDismissibleSnackBar( - context, - content: Text( - value - ? context.l10n.appSettings_autoRouteRotationEnabled - : context.l10n.appSettings_autoRouteRotationDisabled, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.appSettings_timeFilter, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + settingsService.settings.mapTimeFilterHours == 0 + ? context.l10n.appSettings_timeFilterShowAll + : context.l10n.appSettings_timeFilterShowLast( + settingsService + .settings + .mapTimeFilterHours + .toInt(), + ), + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), ), - duration: const Duration(seconds: 2), - ); - }, - ), - if (settingsService.settings.autoRouteRotationEnabled) - Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - padding: const EdgeInsets.only(left: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider(height: 1), - ListTile( - title: Text(context.l10n.appSettings_maxRouteWeight), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.appSettings_maxRouteWeightSubtitle), - Slider( - value: settingsService.settings.maxRouteWeight, - min: 1, - max: 10, - divisions: 9, - label: settingsService.settings.maxRouteWeight - .round() - .toString(), - onChanged: (value) => - settingsService.setMaxRouteWeight(value), - ), - ], - ), - ), - const Divider(height: 1), - ListTile( - title: Text(context.l10n.appSettings_initialRouteWeight), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.appSettings_initialRouteWeightSubtitle, - ), - Slider( - value: settingsService.settings.initialRouteWeight, - min: 0.5, - max: 5.0, - divisions: 9, - label: settingsService.settings.initialRouteWeight - .toStringAsFixed(1), - onChanged: (value) => - settingsService.setInitialRouteWeight(value), - ), - ], - ), - ), - const Divider(height: 1), - ListTile( - title: Text( - context.l10n.appSettings_routeWeightSuccessIncrement, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context - .l10n - .appSettings_routeWeightSuccessIncrementSubtitle, - ), - Slider( - value: settingsService - .settings - .routeWeightSuccessIncrement, - min: 0.1, - max: 2.0, - divisions: 19, - label: settingsService - .settings - .routeWeightSuccessIncrement - .toStringAsFixed(1), - onChanged: (value) => settingsService - .setRouteWeightSuccessIncrement(value), - ), - ], - ), - ), - const Divider(height: 1), - ListTile( - title: Text( - context.l10n.appSettings_routeWeightFailureDecrement, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context - .l10n - .appSettings_routeWeightFailureDecrementSubtitle, - ), - Slider( - value: settingsService - .settings - .routeWeightFailureDecrement, - min: 0.1, - max: 2.0, - divisions: 19, - label: settingsService - .settings - .routeWeightFailureDecrement - .toStringAsFixed(1), - onChanged: (value) => settingsService - .setRouteWeightFailureDecrement(value), - ), - ], - ), - ), - const Divider(height: 1), - ListTile( - title: Text(context.l10n.appSettings_maxMessageRetries), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.appSettings_maxMessageRetriesSubtitle, - ), - Slider( - value: settingsService.settings.maxMessageRetries - .toDouble(), - min: 2, - max: 10, - divisions: 8, - label: settingsService.settings.maxMessageRetries - .toString(), - onChanged: (value) => settingsService - .setMaxMessageRetries(value.toInt()), - ), - ], - ), - ), - ], - ), + Icon( + Icons.chevron_right, + color: scheme.onSurfaceVariant, + size: 16, + ), + ], ), - const Divider(height: 1), - SwitchListTile( - secondary: const Icon(Icons.location_searching), - title: Text(context.l10n.appSettings_enableMessageTracing), - subtitle: Text( - context.l10n.appSettings_enableMessageTracingSubtitle, - ), - value: settingsService.settings.enableMessageTracing, - onChanged: (value) { - settingsService.setEnableMessageTracing(value); - }, ), - ], - ), + ), + const Divider(height: 1, indent: 16), + InkWell( + onTap: () => _showUnitsSheet(context, settingsService), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.straighten, + size: 20, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.appSettings_unitsTitle, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + settingsService.settings.unitSystem == + UnitSystem.imperial + ? context.l10n.appSettings_unitsImperial + : context.l10n.appSettings_unitsMetric, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: scheme.onSurfaceVariant, + size: 16, + ), + ], + ), + ), + ), + const Divider(height: 1, indent: 16), + InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const MapCacheScreen()), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.download_outlined, + size: 20, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.appSettings_offlineMapCache, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + settingsService.settings.mapCacheBounds == null + ? context.l10n.appSettings_noAreaSelected + : context.l10n.appSettings_areaSelectedZoom( + settingsService.settings.mapCacheMinZoom, + settingsService.settings.mapCacheMaxZoom, + ), + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: scheme.onSurfaceVariant, + size: 16, + ), + ], + ), + ), + ), + ], ); } - Widget _buildMapSettingsCard( - BuildContext context, - AppSettingsService settingsService, - ) { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - context.l10n.appSettings_mapDisplay, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - SwitchListTile( - secondary: const Icon(Icons.router_outlined), - title: Text(context.l10n.appSettings_showRepeaters), - subtitle: Text(context.l10n.appSettings_showRepeatersSubtitle), - value: settingsService.settings.mapShowRepeaters, - onChanged: (value) { - settingsService.setMapShowRepeaters(value); - }, - ), - const Divider(height: 1), - SwitchListTile( - secondary: const Icon(Icons.chat_outlined), - title: Text(context.l10n.appSettings_showChatNodes), - subtitle: Text(context.l10n.appSettings_showChatNodesSubtitle), - value: settingsService.settings.mapShowChatNodes, - onChanged: (value) { - settingsService.setMapShowChatNodes(value); - }, - ), - const Divider(height: 1), - SwitchListTile( - secondary: const Icon(Icons.people_outline), - title: Text(context.l10n.appSettings_showOtherNodes), - subtitle: Text(context.l10n.appSettings_showOtherNodesSubtitle), - value: settingsService.settings.mapShowOtherNodes, - onChanged: (value) { - settingsService.setMapShowOtherNodes(value); - }, - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.timer_outlined), - title: Text(context.l10n.appSettings_timeFilter), - subtitle: Text( - settingsService.settings.mapTimeFilterHours == 0 - ? context.l10n.appSettings_timeFilterShowAll - : context.l10n.appSettings_timeFilterShowLast( - settingsService.settings.mapTimeFilterHours.toInt(), - ), - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => _showTimeFilterDialog(context, settingsService), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.straighten), - title: Text(context.l10n.appSettings_unitsTitle), - subtitle: Text( - settingsService.settings.unitSystem == UnitSystem.imperial - ? context.l10n.appSettings_unitsImperial - : context.l10n.appSettings_unitsMetric, - ), - trailing: const Icon(Icons.chevron_right), - onTap: () => _showUnitsDialog(context, settingsService), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.download_outlined), - title: Text(context.l10n.appSettings_offlineMapCache), - subtitle: Text( - settingsService.settings.mapCacheBounds == null - ? context.l10n.appSettings_noAreaSelected - : context.l10n.appSettings_areaSelectedZoom( - settingsService.settings.mapCacheMinZoom, - settingsService.settings.mapCacheMaxZoom, - ), - ), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const MapCacheScreen()), - ); - }, - ), - ], - ), - ); - } - - Widget _buildTranslationCard( + Widget _buildTranslationContent( BuildContext context, AppSettingsService settingsService, TranslationService translationService, ) { final settings = settingsService.settings; final translationEnabled = settings.translationEnabled; - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - context.l10n.translation_title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: const Icon(Icons.translate, size: 20), + title: Text(context.l10n.translation_enableTitle), + subtitle: Text(context.l10n.translation_enableSubtitle), + value: settings.translationEnabled, + onChanged: settingsService.setTranslationEnabled, + ), + const Divider(height: 1, indent: 16), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + secondary: Icon( + Icons.auto_awesome_outlined, + size: 20, + color: translationEnabled ? null : Theme.of(context).disabledColor, + ), + title: Text( + context.l10n.translation_autoIncomingTitle, + style: TextStyle( + color: + translationEnabled ? null : Theme.of(context).disabledColor, ), ), - SwitchListTile( - secondary: const Icon(Icons.translate), - title: Text(context.l10n.translation_enableTitle), - subtitle: Text(context.l10n.translation_enableSubtitle), - value: settings.translationEnabled, - onChanged: settingsService.setTranslationEnabled, + subtitle: Text( + context.l10n.translation_autoIncomingSubtitle, + style: TextStyle( + color: + translationEnabled ? null : Theme.of(context).disabledColor, + ), ), - const Divider(height: 1), - SwitchListTile( - secondary: Icon( - Icons.auto_awesome_outlined, - color: translationEnabled - ? null - : Theme.of(context).disabledColor, - ), - title: Text( - context.l10n.translation_autoIncomingTitle, - style: TextStyle( - color: translationEnabled - ? null - : Theme.of(context).disabledColor, - ), - ), - subtitle: Text( - context.l10n.translation_autoIncomingSubtitle, - style: TextStyle( - color: translationEnabled - ? null - : Theme.of(context).disabledColor, - ), - ), - value: settings.autoTranslateIncomingMessages, - onChanged: translationEnabled - ? settingsService.setAutoTranslateIncomingMessages - : null, + value: settings.autoTranslateIncomingMessages, + onChanged: translationEnabled + ? settingsService.setAutoTranslateIncomingMessages + : null, + ), + const Divider(height: 1, indent: 16), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, ), - const Divider(height: 1), - SwitchListTile( - secondary: Icon( - Icons.outgoing_mail, - color: translationEnabled - ? null - : Theme.of(context).disabledColor, - ), - title: Text( - context.l10n.translation_composerTitle, - style: TextStyle( - color: translationEnabled - ? null - : Theme.of(context).disabledColor, - ), - ), - subtitle: Text( - context.l10n.translation_composerSubtitle, - style: TextStyle( - color: translationEnabled - ? null - : Theme.of(context).disabledColor, - ), - ), - value: settings.composerTranslationEnabled, - onChanged: translationEnabled - ? settingsService.setComposerTranslationEnabled - : null, + secondary: Icon( + Icons.outgoing_mail, + size: 20, + color: translationEnabled ? null : Theme.of(context).disabledColor, ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.language), - title: Text(context.l10n.translation_targetLanguage), - subtitle: Text( - _translationLanguageLabel( - context, - settings.translationTargetLanguageCode, - ), + title: Text( + context.l10n.translation_composerTitle, + style: TextStyle( + color: + translationEnabled ? null : Theme.of(context).disabledColor, ), - trailing: const Icon(Icons.chevron_right), - onTap: () => - _showTranslationLanguageDialog(context, settingsService), ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: DropdownButtonFormField( - initialValue: settings.translationSelectedModelId, - isExpanded: true, - decoration: InputDecoration( - labelText: context.l10n.translation_downloadedModelLabel, - border: const OutlineInputBorder(), - ), - items: [ - for (final model in settings.translationDownloadedModels) - DropdownMenuItem( - value: model.id, - child: Text(translationModelFriendlyName(model)), + subtitle: Text( + context.l10n.translation_composerSubtitle, + style: TextStyle( + color: + translationEnabled ? null : Theme.of(context).disabledColor, + ), + ), + value: settings.composerTranslationEnabled, + onChanged: translationEnabled + ? settingsService.setComposerTranslationEnabled + : null, + ), + const Divider(height: 1, indent: 16), + InkWell( + onTap: () => + _showTranslationLanguageDialog(context, settingsService), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.language, + size: 20, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.translation_targetLanguage, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + _translationLanguageLabel( + context, + settings.translationTargetLanguageCode, + ), + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], ), + ), + Icon( + Icons.chevron_right, + color: scheme.onSurfaceVariant, + size: 16, + ), ], - onChanged: settings.translationDownloadedModels.isEmpty - ? null - : (value) { - settingsService.setTranslationSelectedModelId(value); - }, ), ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: DropdownButtonFormField( - initialValue: null, - isExpanded: true, - decoration: InputDecoration( - labelText: context.l10n.translation_presetModelLabel, - border: const OutlineInputBorder(), - ), - items: [ - for (final preset in translationPresetModels) - DropdownMenuItem( - value: preset.sourceUrl, - child: Text(translationModelFriendlyName(preset)), - ), - ], - onChanged: translationService.isBusy - ? null - : (value) async { - if (value == null) return; - final preset = translationPresetModels.firstWhere( - (entry) => entry.sourceUrl == value, - ); - await _downloadTranslationModel( + ), + const Divider(height: 1, indent: 16), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: DropdownButtonFormField( + initialValue: settings.translationSelectedModelId, + isExpanded: true, + decoration: InputDecoration( + labelText: context.l10n.translation_downloadedModelLabel, + border: const OutlineInputBorder(), + ), + items: [ + for (final model in settings.translationDownloadedModels) + DropdownMenuItem( + value: model.id, + child: Text(translationModelFriendlyName(model)), + ), + ], + onChanged: settings.translationDownloadedModels.isEmpty + ? null + : (value) { + settingsService.setTranslationSelectedModelId(value); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: DropdownButtonFormField( + initialValue: null, + isExpanded: true, + decoration: InputDecoration( + labelText: context.l10n.translation_presetModelLabel, + border: const OutlineInputBorder(), + ), + items: [ + for (final preset in translationPresetModels) + DropdownMenuItem( + value: preset.sourceUrl, + child: Text(translationModelFriendlyName(preset)), + ), + ], + onChanged: translationService.isBusy + ? null + : (value) async { + if (value == null) return; + final preset = translationPresetModels.firstWhere( + (entry) => entry.sourceUrl == value, + ); + await _downloadTranslationModel( + context, + translationService, + settingsService, + sourceUrl: preset.sourceUrl, + fileName: preset.name, + id: preset.id, + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TranslationUrlField( + initialValue: settings.translationModelSourceUrl ?? '', + onChanged: settingsService.setTranslationModelSourceUrl, + onDownload: translationService.isBusy + ? null + : (url) => _downloadTranslationModel( context, translationService, settingsService, - sourceUrl: preset.sourceUrl, - fileName: preset.name, - id: preset.id, - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - child: Column( - children: [ - _TranslationUrlField( - initialValue: settings.translationModelSourceUrl ?? '', - onChanged: settingsService.setTranslationModelSourceUrl, - onDownload: translationService.isBusy - ? null - : (url) => _downloadTranslationModel( - context, - translationService, - settingsService, - sourceUrl: url, - ), - downloadLabel: translationService.isDownloading - ? context.l10n.translation_downloading - : translationService.isBusy - ? context.l10n.translation_working - : context.l10n.translation_downloadModel, - isDownloading: translationService.isDownloading, - onCancel: translationService.cancelDownload, - labelText: context.l10n.translation_manualUrlLabel, - stopLabel: context.l10n.translation_stop, - ), - if (translationService.isDownloading) ...[ - const SizedBox(height: 12), - LinearProgressIndicator( - value: - translationService.downloadFileName == + sourceUrl: url, + ), + downloadLabel: translationService.isDownloading + ? context.l10n.translation_downloading + : translationService.isBusy + ? context.l10n.translation_working + : context.l10n.translation_downloadModel, + isDownloading: translationService.isDownloading, + onCancel: translationService.cancelDownload, + labelText: context.l10n.translation_manualUrlLabel, + stopLabel: context.l10n.translation_stop, + ), + if (translationService.isDownloading) ...[ + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: translationService.downloadFileName == 'Merging chunks...' ? null : translationService.downloadProgress, ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: Text( - _downloadProgressLabel(context, translationService), - style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text( + _downloadProgressLabel(context, translationService), + style: MeshTheme.mono( + fontSize: 12, + color: scheme.onSurfaceVariant, ), ), - ], - if (settings.translationDownloadedModels.isNotEmpty) ...[ - const SizedBox(height: 16), - Align( - alignment: Alignment.centerLeft, - child: Text( - context.l10n.translation_downloadedModels, - style: Theme.of(context).textTheme.titleSmall, - ), + ), + ], + if (settings.translationDownloadedModels.isNotEmpty) ...[ + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n.translation_downloadedModels, + style: Theme.of(context).textTheme.titleSmall, ), - const SizedBox(height: 8), - for (final model in settings.translationDownloadedModels) - Card.outlined( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - leading: Icon( + ), + const SizedBox(height: 8), + for (final model in settings.translationDownloadedModels) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon( model.id == settings.translationSelectedModelId ? Icons.check_circle : Icons.memory_outlined, + size: 20, + color: model.id == settings.translationSelectedModelId + ? scheme.primary + : scheme.onSurfaceVariant, ), - title: Text(translationModelFriendlyName(model)), - subtitle: Text(_downloadedModelLabel(model)), - trailing: IconButton( + const SizedBox(width: 12), + Expanded( + child: InkWell( + borderRadius: + BorderRadius.circular(MeshRadii.xs), + onTap: () => settingsService + .setTranslationSelectedModelId(model.id), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + translationModelFriendlyName(model), + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _downloadedModelLabel(model), + style: MeshTheme.mono( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + IconButton( tooltip: context.l10n.translation_deleteModel, onPressed: translationService.isBusy ? null @@ -831,105 +1186,119 @@ class AppSettingsScreen extends StatelessWidget { ), icon: const Icon(Icons.delete_outline), ), - onTap: () => settingsService - .setTranslationSelectedModelId(model.id), - ), - ), - ], - if (translationService.lastError != null) ...[ - const SizedBox(height: 8), - Text( - translationService.lastError!, - style: TextStyle( - color: Theme.of(context).colorScheme.error, + ], ), ), - ], ], - ), + if (translationService.lastError != null) ...[ + const SizedBox(height: 8), + Text( + translationService.lastError!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ], ), - ], - ), + ), + ], ); } - // Fixed rendering issues - Widget _buildBatteryCard( + Widget _buildCyr2LatContent( BuildContext context, AppSettingsService settingsService, - MeshCoreConnector connector, ) { - final deviceId = connector.deviceId; - final isConnected = connector.isConnected && deviceId != null; - final selection = isConnected - ? settingsService.batteryChemistryForDevice(deviceId) - : 'nmc'; - - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - context.l10n.appSettings_battery, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), + final selectedProfile = settingsService.getSelectedCyr2LatProfile(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: settingsService.settings.selectedCyr2latProfileId, + decoration: InputDecoration( + labelText: context.l10n.channels_cyr2latSettingsSubheading, + border: const OutlineInputBorder(), ), - - // Main tile (icon + text only) - ListTile( - leading: const Icon(Icons.battery_full), - title: Text(context.l10n.appSettings_batteryChemistry), - subtitle: Text( - isConnected - ? context.l10n.appSettings_batteryChemistryPerDevice( - connector.deviceDisplayName, - ) - : context.l10n.appSettings_batteryChemistryConnectFirst, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - ), - - // Dropdown (separate full-width row) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: DropdownButtonFormField( - initialValue: selection, - isExpanded: true, - decoration: const InputDecoration( - border: UnderlineInputBorder(), - isDense: true, + items: settingsService.settings.cyr2latProfiles.map((profile) { + return DropdownMenuItem( + value: profile.id, + child: Text(profile.name), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + settingsService.setSelectedCyr2LatProfile(value); + } + }, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => + _showAddCyr2LatProfileDialog(context, settingsService), + icon: const Icon(Icons.add), + label: Text(context.l10n.common_add), ), - onChanged: isConnected - ? (value) { - if (value != null) { - settingsService.setBatteryChemistryForDevice( - deviceId, - value, - ); - } - } - : null, - items: [ - DropdownMenuItem( - value: 'nmc', - child: Text(context.l10n.appSettings_batteryNmc), - ), - DropdownMenuItem( - value: 'lifepo4', - child: Text(context.l10n.appSettings_batteryLifepo4), - ), - DropdownMenuItem( - value: 'lipo', - child: Text(context.l10n.appSettings_batteryLipo), - ), - ], ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () => _showEditCyr2LatProfileDialog( + context, + settingsService, + selectedProfile, + ), + icon: const Icon(Icons.edit), + label: Text(context.l10n.common_edit), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: settingsService.settings.cyr2latProfiles.length > 1 + ? () => _showDeleteCyr2LatProfileDialog( + context, + settingsService, + selectedProfile, + ) + : null, + icon: const Icon(Icons.delete), + label: Text(context.l10n.common_delete), + ), + ), + ], + ), + ], + ); + } + + Widget _buildDebugContent( + BuildContext context, + AppSettingsService settingsService, + ) { + return SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + secondary: const Icon(Icons.bug_report_outlined, size: 20), + title: Text(context.l10n.appSettings_appDebugLogging), + subtitle: Text(context.l10n.appSettings_appDebugLoggingSubtitle), + value: settingsService.settings.appDebugLogEnabled, + onChanged: (value) async { + await settingsService.setAppDebugLogEnabled(value); + if (!context.mounted) return; + showDismissibleSnackBar( + context, + content: Text( + value + ? context.l10n.appSettings_appDebugLoggingEnabled + : context.l10n.appSettings_appDebugLoggingDisabled, ), - ], - ), + duration: const Duration(seconds: 2), + ); + }, ); } @@ -976,208 +1345,363 @@ class AppSettingsScreen extends StatelessWidget { } } - void _showLanguageDialog( + void _showLanguageSheet( BuildContext context, AppSettingsService settingsService, ) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.appSettings_language), - content: SingleChildScrollView( - child: RadioGroup( - groupValue: settingsService.settings.languageOverride, - onChanged: (value) { - settingsService.setLanguageOverride(value); - Navigator.pop(context); - }, - child: Column( - mainAxisSize: MainAxisSize.min, + showMeshSheet( + context, + builder: (ctx) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + BottomSheetHeader(title: context.l10n.appSettings_language), + SizedBox( + height: 400, + child: ListView( children: [ - RadioListTile( - title: Text(context.l10n.appSettings_languageSystem), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageSystem, value: null, + selected: settingsService.settings.languageOverride == null, + onTap: () { + settingsService.setLanguageOverride(null); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageEn), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageEn, value: 'en', + selected: + settingsService.settings.languageOverride == 'en', + onTap: () { + settingsService.setLanguageOverride('en'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageFr), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageFr, value: 'fr', + selected: + settingsService.settings.languageOverride == 'fr', + onTap: () { + settingsService.setLanguageOverride('fr'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageEs), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageEs, value: 'es', + selected: + settingsService.settings.languageOverride == 'es', + onTap: () { + settingsService.setLanguageOverride('es'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageDe), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageDe, value: 'de', + selected: + settingsService.settings.languageOverride == 'de', + onTap: () { + settingsService.setLanguageOverride('de'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languagePl), + _sheetOption( + ctx, + label: context.l10n.appSettings_languagePl, value: 'pl', + selected: + settingsService.settings.languageOverride == 'pl', + onTap: () { + settingsService.setLanguageOverride('pl'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageSl), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageSl, value: 'sl', + selected: + settingsService.settings.languageOverride == 'sl', + onTap: () { + settingsService.setLanguageOverride('sl'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languagePt), + _sheetOption( + ctx, + label: context.l10n.appSettings_languagePt, value: 'pt', + selected: + settingsService.settings.languageOverride == 'pt', + onTap: () { + settingsService.setLanguageOverride('pt'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageIt), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageIt, value: 'it', + selected: + settingsService.settings.languageOverride == 'it', + onTap: () { + settingsService.setLanguageOverride('it'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageZh), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageZh, value: 'zh', + selected: + settingsService.settings.languageOverride == 'zh', + onTap: () { + settingsService.setLanguageOverride('zh'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageSv), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageSv, value: 'sv', + selected: + settingsService.settings.languageOverride == 'sv', + onTap: () { + settingsService.setLanguageOverride('sv'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageNl), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageNl, value: 'nl', + selected: + settingsService.settings.languageOverride == 'nl', + onTap: () { + settingsService.setLanguageOverride('nl'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageSk), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageSk, value: 'sk', + selected: + settingsService.settings.languageOverride == 'sk', + onTap: () { + settingsService.setLanguageOverride('sk'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageBg), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageBg, value: 'bg', + selected: + settingsService.settings.languageOverride == 'bg', + onTap: () { + settingsService.setLanguageOverride('bg'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageRu), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageRu, value: 'ru', + selected: + settingsService.settings.languageOverride == 'ru', + onTap: () { + settingsService.setLanguageOverride('ru'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageUk), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageUk, value: 'uk', + selected: + settingsService.settings.languageOverride == 'uk', + onTap: () { + settingsService.setLanguageOverride('uk'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageHu), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageHu, value: 'hu', + selected: + settingsService.settings.languageOverride == 'hu', + onTap: () { + settingsService.setLanguageOverride('hu'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageJa), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageJa, value: 'ja', + selected: + settingsService.settings.languageOverride == 'ja', + onTap: () { + settingsService.setLanguageOverride('ja'); + Navigator.pop(ctx); + }, ), - RadioListTile( - title: Text(context.l10n.appSettings_languageKo), + _sheetOption( + ctx, + label: context.l10n.appSettings_languageKo, value: 'ko', + selected: + settingsService.settings.languageOverride == 'ko', + onTap: () { + settingsService.setLanguageOverride('ko'); + Navigator.pop(ctx); + }, ), + SizedBox(height: MediaQuery.paddingOf(ctx).bottom + 8), ], ), ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.common_close), - ), ], ), ); } - void _showTimeFilterDialog( + void _showTimeFilterSheet( BuildContext context, AppSettingsService settingsService, ) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.appSettings_mapTimeFilter), - content: RadioGroup( - groupValue: settingsService.settings.mapTimeFilterHours, - onChanged: (value) { - if (value != null) { - settingsService.setMapTimeFilterHours(value); - Navigator.pop(context); - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(context.l10n.appSettings_showNodesDiscoveredWithin), - const SizedBox(height: 16), - RadioListTile( - title: Text(context.l10n.appSettings_allTime), - value: 0, - ), - RadioListTile( - title: Text(context.l10n.appSettings_lastHour), - value: 1, - ), - RadioListTile( - title: Text(context.l10n.appSettings_last6Hours), - value: 6, - ), - RadioListTile( - title: Text(context.l10n.appSettings_last24Hours), - value: 24, - ), - RadioListTile( - title: Text(context.l10n.appSettings_lastWeek), - value: 168, - ), - ], + showMeshSheet( + context, + builder: (ctx) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + BottomSheetHeader(title: context.l10n.appSettings_mapTimeFilter), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text(context.l10n.appSettings_showNodesDiscoveredWithin), ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.common_close), + _sheetOption( + ctx, + label: context.l10n.appSettings_allTime, + value: 0, + selected: settingsService.settings.mapTimeFilterHours == 0, + onTap: () { + settingsService.setMapTimeFilterHours(0); + Navigator.pop(ctx); + }, ), + _sheetOption( + ctx, + label: context.l10n.appSettings_lastHour, + value: 1, + selected: settingsService.settings.mapTimeFilterHours == 1, + onTap: () { + settingsService.setMapTimeFilterHours(1); + Navigator.pop(ctx); + }, + ), + _sheetOption( + ctx, + label: context.l10n.appSettings_last6Hours, + value: 6, + selected: settingsService.settings.mapTimeFilterHours == 6, + onTap: () { + settingsService.setMapTimeFilterHours(6); + Navigator.pop(ctx); + }, + ), + _sheetOption( + ctx, + label: context.l10n.appSettings_last24Hours, + value: 24, + selected: settingsService.settings.mapTimeFilterHours == 24, + onTap: () { + settingsService.setMapTimeFilterHours(24); + Navigator.pop(ctx); + }, + ), + _sheetOption( + ctx, + label: context.l10n.appSettings_lastWeek, + value: 168, + selected: settingsService.settings.mapTimeFilterHours == 168, + onTap: () { + settingsService.setMapTimeFilterHours(168); + Navigator.pop(ctx); + }, + ), + SizedBox(height: MediaQuery.paddingOf(ctx).bottom + 8), ], ), ); } - void _showUnitsDialog( + void _showUnitsSheet( BuildContext context, AppSettingsService settingsService, ) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.l10n.appSettings_unitsTitle), - content: RadioGroup( - groupValue: settingsService.settings.unitSystem, - onChanged: (value) { - if (value != null) { - settingsService.setUnitSystem(value); - Navigator.pop(context); - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - RadioListTile( - title: Text(context.l10n.appSettings_unitsMetric), - value: UnitSystem.metric, - ), - RadioListTile( - title: Text(context.l10n.appSettings_unitsImperial), - value: UnitSystem.imperial, - ), - ], + showMeshSheet( + context, + builder: (ctx) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + BottomSheetHeader(title: context.l10n.appSettings_unitsTitle), + _sheetOption( + ctx, + label: context.l10n.appSettings_unitsMetric, + value: UnitSystem.metric, + selected: + settingsService.settings.unitSystem == UnitSystem.metric, + onTap: () { + settingsService.setUnitSystem(UnitSystem.metric); + Navigator.pop(ctx); + }, ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.common_close), + _sheetOption( + ctx, + label: context.l10n.appSettings_unitsImperial, + value: UnitSystem.imperial, + selected: + settingsService.settings.unitSystem == UnitSystem.imperial, + onTap: () { + settingsService.setUnitSystem(UnitSystem.imperial); + Navigator.pop(ctx); + }, ), + SizedBox(height: MediaQuery.paddingOf(ctx).bottom + 8), ], ), ); } + Widget _sheetOption( + BuildContext context, { + required String label, + required T value, + required bool selected, + required VoidCallback onTap, + }) { + final scheme = Theme.of(context).colorScheme; + return ListTile( + leading: Icon( + selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: selected ? scheme.primary : scheme.onSurfaceVariant, + ), + title: Text(label), + onTap: onTap, + ); + } + void _showTranslationLanguageDialog( BuildContext context, AppSettingsService settingsService, @@ -1303,90 +1827,6 @@ class AppSettingsScreen extends StatelessWidget { return '${sizeMb.toStringAsFixed(1)} MB • $source'; } - Widget _buildCyr2LatCard( - BuildContext context, - AppSettingsService settingsService, - ) { - final selectedProfile = settingsService.getSelectedCyr2LatProfile(); - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - context.l10n.channels_cyr2latSettingsHeading, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: DropdownButtonFormField( - initialValue: settingsService.settings.selectedCyr2latProfileId, - decoration: InputDecoration( - labelText: context.l10n.channels_cyr2latSettingsSubheading, - border: const OutlineInputBorder(), - ), - items: settingsService.settings.cyr2latProfiles.map((profile) { - return DropdownMenuItem( - value: profile.id, - child: Text(profile.name), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - settingsService.setSelectedCyr2LatProfile(value); - } - }, - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => - _showAddCyr2LatProfileDialog(context, settingsService), - icon: const Icon(Icons.add), - label: Text(context.l10n.common_add), - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: () => _showEditCyr2LatProfileDialog( - context, - settingsService, - selectedProfile, - ), - icon: const Icon(Icons.edit), - label: Text(context.l10n.common_edit), - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: - settingsService.settings.cyr2latProfiles.length > 1 - ? () => _showDeleteCyr2LatProfileDialog( - context, - settingsService, - selectedProfile, - ) - : null, - icon: const Icon(Icons.delete), - label: Text(context.l10n.common_delete), - ), - ), - ], - ), - ), - ], - ), - ); - } - void _showAddCyr2LatProfileDialog( BuildContext context, AppSettingsService settingsService, @@ -1434,7 +1874,9 @@ class AppSettingsScreen extends StatelessWidget { if (nameController.text.isEmpty) { showDismissibleSnackBar( context, - content: Text(context.l10n.settings_cyr2latProfileNameEmpty), + content: Text( + context.l10n.settings_cyr2latProfileNameEmpty, + ), ); return; } @@ -1522,7 +1964,9 @@ class AppSettingsScreen extends StatelessWidget { if (nameController.text.isEmpty) { showDismissibleSnackBar( context, - content: Text(context.l10n.settings_cyr2latProfileNameEmpty), + content: Text( + context.l10n.settings_cyr2latProfileNameEmpty, + ), ); return; } @@ -1594,45 +2038,6 @@ class AppSettingsScreen extends StatelessWidget { ), ); } - - Widget _buildDebugCard( - BuildContext context, - AppSettingsService settingsService, - ) { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - context.l10n.appSettings_debugCard, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - SwitchListTile( - secondary: const Icon(Icons.bug_report_outlined), - title: Text(context.l10n.appSettings_appDebugLogging), - subtitle: Text(context.l10n.appSettings_appDebugLoggingSubtitle), - value: settingsService.settings.appDebugLogEnabled, - onChanged: (value) async { - await settingsService.setAppDebugLogEnabled(value); - if (!context.mounted) return; - showDismissibleSnackBar( - context, - content: Text( - value - ? context.l10n.appSettings_appDebugLoggingEnabled - : context.l10n.appSettings_appDebugLoggingDisabled, - ), - duration: const Duration(seconds: 2), - ); - }, - ), - ], - ), - ); - } } /// Owns the [TextEditingController] for the manual model URL field so it diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index 6d186970..c229c2e5 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../l10n/l10n.dart'; import '../services/ble_debug_log_service.dart'; import '../connector/meshcore_protocol.dart'; +import '../theme/mesh_theme.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../helpers/snack_bar_builder.dart'; @@ -32,6 +33,7 @@ class _BleDebugLogScreenState extends State { return Scaffold( appBar: AppBar( title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle), + centerTitle: true, actions: [ IconButton( tooltip: context.l10n.debugLog_copyLog, @@ -101,23 +103,14 @@ class _BleDebugLogScreenState extends State { itemCount: showingFrames ? entries.length : rawEntries.length, - separatorBuilder: (_, _) => const Divider(height: 1), + separatorBuilder: (_, _) => + const Divider(height: 1, color: MeshPalette.line), itemBuilder: (context, index) { if (showingFrames) { final entry = entries[index]; final time = '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; - return ListTile( - dense: true, - title: Text(entry.description), - subtitle: Text('${entry.hexPreview}\n$time'), - isThreeLine: true, - leading: Icon( - entry.outgoing - ? Icons.upload - : Icons.download, - size: 18, - ), + return GestureDetector( onLongPress: () async { await Clipboard.setData( ClipboardData( @@ -131,6 +124,60 @@ class _BleDebugLogScreenState extends State { ), ); }, + child: Container( + color: MeshPalette.bg, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Icon( + entry.outgoing + ? Icons.upload + : Icons.download, + size: 18, + color: entry.outgoing + ? MeshPalette.blue + : MeshPalette.signal, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + entry.description, + style: MeshTheme.mono( + fontSize: 11.5, + color: MeshPalette.ink, + ), + ), + const SizedBox(height: 2), + Text( + entry.hexPreview, + style: MeshTheme.mono( + fontSize: 10, + color: MeshPalette.ink3, + ), + ), + const SizedBox(height: 2), + Text( + time, + style: MeshTheme.mono( + fontSize: 9.5, + color: MeshPalette.ink4, + ), + ), + ], + ), + ), + ], + ), + ), ); } @@ -138,18 +185,65 @@ class _BleDebugLogScreenState extends State { final info = _decodeRawPacket(entry.payload); final time = '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; - return ListTile( - dense: true, - title: Text(info.title), - subtitle: Text('${info.summary}\n$time'), - isThreeLine: true, - leading: const Icon(Icons.download, size: 18), + return GestureDetector( onTap: () => _showRawDialog(context, info), + child: Container( + color: MeshPalette.bg, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.download, + size: 18, + color: MeshPalette.signal, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + info.title, + style: MeshTheme.mono( + fontSize: 11.5, + color: MeshPalette.ink, + ), + ), + const SizedBox(height: 2), + Text( + info.summary, + style: MeshTheme.mono( + fontSize: 10, + color: MeshPalette.ink3, + ), + ), + const SizedBox(height: 2), + Text( + time, + style: MeshTheme.mono( + fontSize: 9.5, + color: MeshPalette.ink4, + ), + ), + ], + ), + ), + ], + ), + ), ); }, ) : Center( - child: Text(context.l10n.debugLog_noBleActivity), + child: Text( + context.l10n.debugLog_noBleActivity, + style: const TextStyle(color: MeshPalette.ink3), + ), ), ), ], diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index e2c63c35..94101ef3 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -25,7 +25,6 @@ import '../models/translation_support.dart'; import '../services/app_settings_service.dart'; import '../services/chat_text_scale_service.dart'; import '../services/translation_service.dart'; -import '../helpers/contact_ui.dart'; import '../widgets/byte_count_input.dart'; import '../widgets/empty_state.dart'; import '../widgets/chat_zoom_wrapper.dart'; @@ -39,6 +38,8 @@ import '../widgets/radio_stats_entry.dart'; import '../widgets/sync_progress_overlay.dart'; import '../widgets/translated_message_content.dart'; import '../widgets/unread_divider.dart'; +import '../theme/mesh_theme.dart'; +import '../widgets/mesh_ui.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; @@ -108,7 +109,7 @@ class _ChannelChatScreenState extends State { totalMessages: messages.length, onJumped: () { if (!mounted) return; - _scrollToMessage(anchor!.messageId); + _scrollToMessage(anchor!.messageId, quiet: true); }, ); }); @@ -193,9 +194,12 @@ class _ChannelChatScreenState extends State { }); } - Future _scrollToMessage(String messageId) async { + Future _scrollToMessage(String messageId, {bool quiet = false}) async { final key = _messageKeys[messageId]; if (key == null) { + // The auto unread-jump can resolve a frame after navigating away; + // a deactivated context can't host a snackbar. + if (quiet || !mounted || !context.mounted) return; showDismissibleSnackBar( context, content: Text(context.l10n.chat_originalMessageNotFound), @@ -491,6 +495,7 @@ class _ChannelChatScreenState extends State { final settingsService = context.watch(); final enableTracing = settingsService.settings.enableMessageTracing; final isOutgoing = message.isOutgoing; + final scheme = Theme.of(context).colorScheme; final gifId = GifHelper.parseGif(message.text); final poi = parseMarkerText(message.text); final translatedDisplayText = @@ -507,9 +512,34 @@ class _ChannelChatScreenState extends State { ? message.pathVariants.first : Uint8List(0)); + // Bubble colors — outgoing uses MeshPalette.me / meBorder / meInk. + final bubbleColor = isOutgoing + ? MeshPalette.me + : scheme.surfaceContainerLow; + final bubbleBorder = isOutgoing + ? MeshPalette.meBorder + : scheme.outlineVariant; + final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface; + final metaColor = textColor.withValues(alpha: 0.65); + const bodyFontSize = 14.0; + + // Asymmetric radius matching chat_screen bubbles. + final borderRadius = isOutgoing + ? const BorderRadius.only( + topLeft: Radius.circular(MeshRadii.lg), + topRight: Radius.circular(MeshRadii.lg), + bottomLeft: Radius.circular(MeshRadii.lg), + bottomRight: Radius.circular(MeshRadii.xs), + ) + : const BorderRadius.only( + topLeft: Radius.circular(MeshRadii.xs), + topRight: Radius.circular(MeshRadii.lg), + bottomLeft: Radius.circular(MeshRadii.lg), + bottomRight: Radius.circular(MeshRadii.lg), + ); + const maxSwipeOffset = 64.0; const replySwipeThreshold = 64.0; - const bodyFontSize = 14.0; final messageBody = LayoutBuilder( builder: (context, constraints) => Column( crossAxisAlignment: isOutgoing @@ -520,11 +550,11 @@ class _ChannelChatScreenState extends State { mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isOutgoing) ...[ _buildAvatar(message.senderName), - const SizedBox(width: 8), + const SizedBox(width: 6), ], Flexible( child: GestureDetector( @@ -540,15 +570,12 @@ class _ChannelChatScreenState extends State { vertical: 8, ), constraints: BoxConstraints( - maxWidth: constraints.maxWidth * 0.65, + maxWidth: constraints.maxWidth * 0.72, ), decoration: BoxDecoration( - color: isOutgoing - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), + color: bubbleColor, + borderRadius: borderRadius, + border: Border.all(color: bubbleBorder, width: 1), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -564,14 +591,14 @@ class _ChannelChatScreenState extends State { : EdgeInsets.zero, child: Text( message.senderName, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, + style: MeshTheme.mono( + fontSize: 11, + fontWeight: FontWeight.w700, + color: _colorForName(message.senderName), ), ), ), - if (gifId == null) const SizedBox(height: 4), + if (gifId == null) const SizedBox(height: 2), ], if (message.replyToMessageId != null) ...[ _buildReplyPreview(message, textScale), @@ -594,13 +621,9 @@ class _ChannelChatScreenState extends State { url: 'https://media.giphy.com/media/$gifId/giphy.gif', backgroundColor: Colors.transparent, - fallbackTextColor: isOutgoing - ? Theme.of(context) - .colorScheme - .onPrimaryContainer - .withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurface - .withValues(alpha: 0.6), + fallbackTextColor: textColor.withValues( + alpha: 0.7, + ), ), ), ], @@ -615,40 +638,48 @@ class _ChannelChatScreenState extends State { displayText: translatedDisplayText, originalText: originalDisplayText, style: TextStyle( + color: textColor, fontSize: bodyFontSize * textScale, ), originalStyle: TextStyle( fontSize: bodyFontSize * textScale, fontStyle: FontStyle.italic, - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.72), + color: textColor.withValues(alpha: 0.72), ), ), ), ], ), if (enableTracing && displayPath.isNotEmpty) ...[ - const SizedBox(height: 4), + const SizedBox(height: 3), Padding( padding: gifId != null ? const EdgeInsets.symmetric(horizontal: 8) : EdgeInsets.zero, - child: Text( - context.l10n.channels_via( - _formatPathPrefixes(displayPath), - ), - style: TextStyle( - fontSize: 11 * textScale, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + RouteChip( + isDirect: (message.pathLength ?? -1) >= 0, + hops: (message.pathLength ?? -1) >= 0 + ? message.pathLength + : null, + ), + const SizedBox(width: 4), + Text( + context.l10n.channels_via( + _formatPathPrefixes(displayPath), + ), + style: MeshTheme.mono( + fontSize: 9.5 * textScale, + color: metaColor, + ), + ), + ], ), ), ], - const SizedBox(height: 4), + const SizedBox(height: 3), Padding( padding: gifId != null ? const EdgeInsets.only( @@ -662,30 +693,24 @@ class _ChannelChatScreenState extends State { children: [ Text( _formatTime(context, message.timestamp), - style: TextStyle( - fontSize: 11 * textScale, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + style: MeshTheme.mono( + fontSize: 10 * textScale, + color: metaColor, ), ), if (enableTracing && message.repeatCount > 0) ...[ const SizedBox(width: 6), Icon( Icons.repeat, - size: 12 * textScale, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + size: 11 * textScale, + color: metaColor, ), const SizedBox(width: 2), Text( '${message.repeatCount}', - style: TextStyle( - fontSize: 11 * textScale, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + style: MeshTheme.mono( + fontSize: 10 * textScale, + color: metaColor, ), ), ], @@ -705,6 +730,7 @@ class _ChannelChatScreenState extends State { isFailed: message.status == ChannelMessageStatus.failed, + onColor: metaColor, ), ], ], @@ -720,7 +746,7 @@ class _ChannelChatScreenState extends State { if (message.reactions.isNotEmpty) ...[ const SizedBox(height: 4), Padding( - padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), + padding: EdgeInsets.only(left: isOutgoing ? 0 : 42), child: _buildReactionsDisplay(message), ), ], @@ -739,7 +765,7 @@ class _ChannelChatScreenState extends State { ); } else { return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 3), child: messageBody, ); } @@ -836,7 +862,7 @@ class _ChannelChatScreenState extends State { padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(MeshRadii.sm), border: Border( left: BorderSide(color: colorScheme.primary, width: 3), ), @@ -849,9 +875,7 @@ class _ChannelChatScreenState extends State { style: TextStyle( fontSize: 11 * textScale, fontWeight: FontWeight.bold, - color: isOwnNode - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, + color: isOwnNode ? colorScheme.primary : colorScheme.onSurface, ), ), const SizedBox(height: 2), @@ -863,6 +887,7 @@ class _ChannelChatScreenState extends State { } Widget _buildReactionsDisplay(ChannelMessage message) { + final scheme = Theme.of(context).colorScheme; return Wrap( spacing: 6, runSpacing: 6, @@ -873,27 +898,29 @@ class _ChannelChatScreenState extends State { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.3), - width: 1, - ), + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(MeshRadii.pill), + border: Border.all(color: scheme.outlineVariant, width: 1), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(emoji, style: const TextStyle(fontSize: 16)), + Text( + emoji, + style: MeshTheme.emoji(fontSize: 16), + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ), + ), if (count > 1) ...[ const SizedBox(width: 4), Text( '$count', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSecondaryContainer, + style: MeshTheme.mono( + fontSize: 11, + fontWeight: FontWeight.w700, + color: scheme.onSurface, ), ), ], @@ -912,20 +939,15 @@ class _ChannelChatScreenState extends State { String senderName, { Widget? trailing, }) { - final colorScheme = Theme.of(context).colorScheme; - final textColor = isOutgoing - ? colorScheme.onPrimaryContainer - : colorScheme.onSurface; + final scheme = Theme.of(context).colorScheme; + final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface; final metaColor = textColor.withValues(alpha: 0.7); - final channelColor = widget.channel.isPublicChannel - ? Colors.orange - : Colors.blue; return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( - icon: Icon(Icons.location_on_outlined, color: channelColor), + icon: Icon(Icons.location_on_outlined, color: scheme.primary), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 40, minHeight: 40), onPressed: () { @@ -991,41 +1013,24 @@ class _ChannelChatScreenState extends State { } Widget _buildAvatar(String senderName) { - final initial = firstCharacterOrEmoji(senderName); - final color = colorForName(senderName); - - return CircleAvatar( - radius: 18, - backgroundColor: color.withValues(alpha: 0.2), - child: Text( - initial, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: color, - ), - ), - ); + return AvatarCircle(name: senderName, size: 32); } Widget _buildReplyBanner(double textScale) { final message = _replyingToMessage!; + final scheme = Theme.of(context).colorScheme; return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, + color: scheme.surfaceContainerHigh, border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1), + bottom: BorderSide(color: scheme.outlineVariant, width: 1), ), ), child: Row( children: [ - Icon( - Icons.reply, - size: 18, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), + Icon(Icons.reply, size: 18, color: scheme.primary), const SizedBox(width: 8), Expanded( child: Column( @@ -1033,10 +1038,10 @@ class _ChannelChatScreenState extends State { children: [ Text( context.l10n.chat_replyingTo(message.senderName), - style: TextStyle( - fontSize: 12 * textScale, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSecondaryContainer, + style: MeshTheme.mono( + fontSize: 11 * textScale, + fontWeight: FontWeight.w700, + color: scheme.primary, ), ), Text( @@ -1045,9 +1050,7 @@ class _ChannelChatScreenState extends State { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11 * textScale, - color: Theme.of( - context, - ).colorScheme.onSecondaryContainer.withValues(alpha: 0.7), + color: scheme.onSurfaceVariant, ), ), ], @@ -1056,7 +1059,7 @@ class _ChannelChatScreenState extends State { IconButton( icon: const Icon(Icons.close, size: 18), onPressed: _cancelReply, - color: Theme.of(context).colorScheme.onSecondaryContainer, + color: scheme.onSurfaceVariant, constraints: const BoxConstraints(minWidth: 44, minHeight: 44), ), ], @@ -1068,6 +1071,7 @@ class _ChannelChatScreenState extends State { final connector = context.watch(); final maxBytes = maxChannelMessageBytes(connector.selfName); final settings = context.watch().settings; + final scheme = Theme.of(context).colorScheme; return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1081,123 +1085,166 @@ class _ChannelChatScreenState extends State { }, ), Container( - padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, -2), - ), - ], + color: scheme.surface, + border: Border( + top: BorderSide(color: scheme.outlineVariant, width: 1), + ), ), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.gif_box), - onPressed: () => _showGifPicker(context), - tooltip: context.l10n.chat_sendGif, - ), - if (settings.translationEnabled) - MessageTranslationButton( - enabled: settings.composerTranslationEnabled, - languageCode: settings.translationTargetLanguageCode, - onPressed: _showTranslationOptions, - ), - Expanded( - child: ValueListenableBuilder( - valueListenable: _textController, - builder: (context, value, child) { - final gifId = GifHelper.parseGif(value.text); - if (gifId != null) { - return Focus( - autofocus: true, - onKeyEvent: (node, event) { - if (event is KeyDownEvent && - (event.logicalKey == LogicalKeyboardKey.enter || - event.logicalKey == - LogicalKeyboardKey.numpadEnter)) { - _sendMessage(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - fallbackTextColor: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.6), - maxSize: 160, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.gif_box), + onPressed: () => _showGifPicker(context), + tooltip: context.l10n.chat_sendGif, + ), + if (settings.translationEnabled) + MessageTranslationButton( + enabled: settings.composerTranslationEnabled, + languageCode: settings.translationTargetLanguageCode, + onPressed: _showTranslationOptions, + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: _textController, + builder: (context, value, child) { + final gifId = GifHelper.parseGif(value.text); + if (gifId != null) { + return Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + (event.logicalKey == + LogicalKeyboardKey.enter || + event.logicalKey == + LogicalKeyboardKey.numpadEnter)) { + _sendMessage(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: GifMessage( + url: + 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: + scheme.surfaceContainerHighest, + fallbackTextColor: scheme.onSurface + .withValues(alpha: 0.6), + maxSize: 160, + ), + ), ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _textController.clear(); + _textFieldFocusNode.requestFocus(); + }, + ), + ], + ), + ); + } + return ByteCountedTextField( + maxBytes: maxBytes, + controller: _textController, + focusNode: _textFieldFocusNode, + hintText: context.l10n.chat_typeMessage, + onSubmitted: (_) => _sendMessage(), + encoder: + (connector.isChannelSmazEnabled( + widget.channel.index, + ) || + connector.isChannelCyr2LatEnabled( + widget.channel.index, + )) + ? (text) => connector.prepareChannelOutboundText( + widget.channel.index, + text, + ) + : null, + decoration: InputDecoration( + hintText: context.l10n.chat_typeMessage, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular( + MeshRadii.pill, + ), + borderSide: BorderSide( + color: scheme.outlineVariant, ), ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.close), - onPressed: () { - _textController.clear(); - _textFieldFocusNode.requestFocus(); - }, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + MeshRadii.pill, + ), + borderSide: BorderSide( + color: scheme.outlineVariant, + ), ), - ], + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular( + MeshRadii.pill, + ), + borderSide: BorderSide( + color: scheme.primary, + width: 1.5, + ), + ), + filled: true, + fillColor: scheme.surfaceContainerLow, + contentPadding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), + ), + ); + }, + ), + ), + const SizedBox(width: 6), + ValueListenableBuilder( + valueListenable: _textController, + builder: (context, value, _) { + final hasText = value.text.trim().isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeInOut, + child: IconButton.filled( + icon: const Icon(Icons.send, size: 20), + tooltip: context.l10n.chat_sendMessage, + style: IconButton.styleFrom( + backgroundColor: hasText + ? scheme.primary + : scheme.surfaceContainerHighest, + foregroundColor: hasText + ? scheme.onPrimary + : scheme.onSurfaceVariant, + minimumSize: const Size(40, 40), + shape: const CircleBorder(), + ), + onPressed: hasText + ? () { + HapticFeedback.lightImpact(); + _sendMessage(); + } + : null, ), ); - } - return ByteCountedTextField( - maxBytes: maxBytes, - controller: _textController, - focusNode: _textFieldFocusNode, - hintText: context.l10n.chat_typeMessage, - onSubmitted: (_) => _sendMessage(), - encoder: - (connector.isChannelSmazEnabled( - widget.channel.index, - ) || - connector.isChannelCyr2LatEnabled( - widget.channel.index, - )) - ? (text) => connector.prepareChannelOutboundText( - widget.channel.index, - text, - ) - : null, - decoration: InputDecoration( - hintText: context.l10n.chat_typeMessage, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - ), - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.surfaceContainerLow, - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 14, - ), - ), - ); - }, - ), + }, + ), + ], ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.send), - tooltip: context.l10n.chat_sendMessage, - onPressed: _sendMessage, - color: Theme.of(context).colorScheme.primary, - ), - ], + ), ), ), ], @@ -1338,12 +1385,20 @@ class _ChannelChatScreenState extends State { ) && (message.translatedText?.trim().isEmpty ?? true); - showModalBottomSheet( - context: context, + showMeshSheet( + context, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ + BottomSheetHeader( + title: message.text.length > 40 + ? '${message.text.substring(0, 40)}…' + : message.text, + subtitle: message.senderName.isNotEmpty + ? message.senderName + : null, + ), ListTile( leading: const Icon(Icons.reply), title: Text(context.l10n.chat_reply), @@ -1402,7 +1457,7 @@ class _ChannelChatScreenState extends State { _markAsUnread(message); }, ), - const Divider(), + const Divider(height: 1), ListTile( leading: Icon( Icons.delete_outline, @@ -1417,6 +1472,7 @@ class _ChannelChatScreenState extends State { await _deleteMessage(message); }, ), + const SizedBox(height: 8), ], ), ), @@ -1499,6 +1555,23 @@ class _ChannelChatScreenState extends State { .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(','); } + + /// Deterministic name-to-hue mapping consistent with [AvatarCircle]. + Color _colorForName(String name) { + const hues = [ + MeshPalette.blue, + MeshPalette.magenta, + MeshPalette.signal, + MeshPalette.warn, + Color(0xFF8FA8F0), + Color(0xFF6FD9CE), + ]; + var h = 0; + for (final c in name.codeUnits) { + h = (h * 31 + c) & 0x7fffffff; + } + return hues[h % hues.length]; + } } class _SwipeReplyBubble extends StatefulWidget { @@ -1640,7 +1713,7 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> { onPointerUp: (event) => _handleSwipePointerUp(event.position), onPointerCancel: (_) => _resetSwipe(), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 8), child: Stack( alignment: Alignment.center, children: [ diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index e7f21459..13bc0c3c 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -16,7 +16,13 @@ import '../l10n/l10n.dart'; import '../models/channel_message.dart'; import '../models/app_settings.dart'; import '../models/contact.dart'; +import '../models/display_path.dart'; +import '../models/path_playback.dart'; +import '../theme/mesh_theme.dart'; import '../widgets/adaptive_app_bar_title.dart'; +import '../widgets/mesh_ui.dart'; +import '../widgets/path_map_ui.dart'; +import '../widgets/themed_map_tile_layer.dart'; class ChannelMessagePathScreen extends StatelessWidget { final ChannelMessage message; @@ -85,33 +91,25 @@ class ChannelMessagePathScreen extends StatelessWidget { body: SafeArea( top: false, child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(vertical: 8), children: [ _buildSummaryCard(context, observedLabel: observedLabel), - const SizedBox(height: 16), if (extraPaths.isNotEmpty) ...[ - Text( + SectionHeader( l10n.channelPath_otherObservedPaths, - style: Theme.of(context).textTheme.titleSmall, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), ), - const SizedBox(height: 8), _buildPathVariants(context, extraPaths), - const SizedBox(height: 16), ], - Text( + SectionHeader( l10n.channelPath_repeaterHops, - style: Theme.of(context).textTheme.titleSmall, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), ), - const SizedBox(height: 8), if (!hasHopDetails) - Text( - l10n.channelPath_noHopDetails, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ) + _buildNoHopCard(context, l10n) else - ..._buildHopTiles(context, hops), + _buildHopTimeline(context, hops, l10n), + const SizedBox(height: 16), ], ), ), @@ -122,46 +120,64 @@ class ChannelMessagePathScreen extends StatelessWidget { Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) { final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.channelPath_messageDetails, - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - _buildDetailRow( - context, - l10n.channelPath_senderLabel, - message.senderName, - ), - _buildDetailRow( - context, - l10n.channelPath_timeLabel, - _formatTime(message.timestamp, l10n), - ), - if (message.repeatCount > 0) - _buildDetailRow( - context, - l10n.channelPath_repeatsLabel, - message.repeatCount.toString(), + final scheme = Theme.of(context).colorScheme; + final routeChip = message.pathLength == null + ? null + : message.pathLength! < 0 + ? const RouteChip(isDirect: false) + : RouteChip(isDirect: true, hops: message.pathLength); + + return MeshCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: SectionHeader( + l10n.channelPath_messageDetails, + padding: EdgeInsets.zero, + ), ), + ?routeChip, + ], + ), + const SizedBox(height: 10), + _buildDetailRow( + context, + l10n.channelPath_senderLabel, + message.senderName, + scheme: scheme, + ), + _buildDetailRow( + context, + l10n.channelPath_timeLabel, + _formatTime(message.timestamp, l10n), + scheme: scheme, + ), + if (message.repeatCount > 0) _buildDetailRow( context, - l10n.channelPath_pathLabelTitle, - _formatPathLabel(message.pathLength, l10n), + l10n.channelPath_repeatsLabel, + message.repeatCount.toString(), + scheme: scheme, ), - if (observedLabel != null) - _buildDetailRow( - context, - l10n.channelPath_observedLabel, - observedLabel, - ), - ], - ), + _buildDetailRow( + context, + l10n.channelPath_pathLabelTitle, + _formatPathLabel(message.pathLength, l10n), + scheme: scheme, + ), + if (observedLabel != null) + _buildDetailRow( + context, + l10n.channelPath_observedLabel, + observedLabel, + scheme: scheme, + ), + ], ), ); } @@ -172,54 +188,199 @@ class ChannelMessagePathScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (int i = 0; i < variants.length; i++) - Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - dense: true, - title: Text( - l10n.channelPath_observedPathTitle( - i + 1, - _formatHopCount(variants[i].length, l10n), + MeshCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + onTap: () => _openPathMap( + context, + initialPath: variants[i], + channelMessage: channelMessage, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.channelPath_observedPathTitle( + i + 1, + _formatHopCount(variants[i].length, l10n), + ), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + _formatPathPrefixes(variants[i]), + style: MeshTheme.mono( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), ), - ), - subtitle: Text(_formatPathPrefixes(variants[i])), - trailing: const Icon(Icons.map_outlined, size: 20), - onTap: () => _openPathMap( - context, - initialPath: variants[i], - channelMessage: channelMessage, - ), + const SizedBox(width: 8), + Icon( + Icons.map_outlined, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], ), ), ], ); } - List _buildHopTiles(BuildContext context, List<_PathHop> hops) { - final l10n = context.l10n; - return [ - for (final hop in hops) - Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - dense: true, - leading: CircleAvatar( - radius: 14, - child: Text( - hop.index.toString(), - style: const TextStyle(fontSize: 12), - ), - ), - title: Text(hop.displayLabel), - subtitle: Text( - hop.hasLocation - ? '${hop.position!.latitude.toStringAsFixed(5)}, ' - '${hop.position!.longitude.toStringAsFixed(5)}' - : l10n.channelPath_noLocationData, + Widget _buildNoHopCard(BuildContext context, AppLocalizations l10n) { + final scheme = Theme.of(context).colorScheme; + return MeshCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Icon(Icons.route_outlined, size: 20, color: scheme.onSurfaceVariant), + const SizedBox(width: 10), + Expanded( + child: Text( + l10n.channelPath_noHopDetails, + style: TextStyle(color: scheme.onSurfaceVariant), ), ), - ), - ]; + ], + ), + ); + } + + Widget _buildHopTimeline( + BuildContext context, + List<_PathHop> hops, + AppLocalizations l10n, + ) { + if (hops.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + for (int i = 0; i < hops.length; i++) + ListEntrance( + index: i, + child: _buildTimelineNode( + context, + hops[i], + l10n, + isLast: i == hops.length - 1, + ), + ), + ], + ), + ); + } + + Widget _buildTimelineNode( + BuildContext context, + _PathHop hop, + AppLocalizations l10n, { + required bool isLast, + }) { + final scheme = Theme.of(context).colorScheme; + final hexPrefix = _formatPrefix(hop.prefix); + final locationText = hop.hasLocation + ? '${hop.position!.latitude.toStringAsFixed(5)}, ' + '${hop.position!.longitude.toStringAsFixed(5)}' + : l10n.channelPath_noLocationData; + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 48, + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + AvatarCircle(name: hop.displayLabel, size: 36), + Positioned( + right: -4, + top: -4, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: MeshPalette.blueDim, + shape: BoxShape.circle, + border: Border.all( + color: scheme.surfaceContainerLow, + width: 1.5, + ), + ), + alignment: Alignment.center, + child: Text( + hop.index.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + if (!isLast) + Expanded( + child: Container( + width: 2, + margin: const EdgeInsets.symmetric(vertical: 4), + color: MeshPalette.blueLine, + ), + ) + else + const SizedBox(height: 12), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 16, top: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hop.displayLabel, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + hexPrefix, + style: MeshTheme.mono( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + locationText, + style: MeshTheme.mono( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ], + ), + ); } String _formatTime(DateTime time, AppLocalizations l10n) { @@ -263,21 +424,25 @@ class ChannelMessagePathScreen extends StatelessWidget { return l10n.channelPath_observedSomeOf(observedCount, pathLength); } - Widget _buildDetailRow(BuildContext context, String label, String value) { + Widget _buildDetailRow( + BuildContext context, + String label, + String value, { + required ColorScheme scheme, + }) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), + padding: const EdgeInsets.symmetric(vertical: 3), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 70, + width: 72, child: Text( - label, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + label.toUpperCase(), + style: MeshTheme.accentLabel(color: scheme.onSurfaceVariant), ), ), + const SizedBox(width: 8), Expanded(child: Text(value)), ], ), @@ -320,7 +485,8 @@ class ChannelMessagePathMapScreen extends StatefulWidget { } class _ChannelMessagePathMapScreenState - extends State { + extends State + with SingleTickerProviderStateMixin { static const double _labelZoomThreshold = 8.5; static const double _mapMinZoom = 2.0; static const double _mapMaxZoom = 18.0; @@ -332,10 +498,39 @@ class _ChannelMessagePathMapScreenState bool _didReceivePositionUpdate = false; int? _focusedHopIndex; + // Packet-flow animation + multi-path view state. + late final PathPlaybackController _playback; + PathViewMode _viewMode = PathViewMode.single; + final Set _hiddenPathIds = {}; + bool _panelCollapsed = false; + bool _animationEnabled = true; + bool _followPacket = false; + @override void initState() { super.initState(); _selectedPath = widget.initialPath; + _playback = PathPlaybackController(this); + _playback.addListener(_followPacketCamera); + } + + /// Keeps the camera centered on the packet while the follow lock is on. + void _followPacketCamera() { + if (!_followPacket || + !_animationEnabled || + !_playback.started || + !_playback.hasPath || + !mounted) { + return; + } + _mapController.move(_playback.position, _mapController.camera.zoom); + } + + void _toggleFollowPacket() { + setState(() { + _followPacket = !_followPacket; + }); + _followPacketCamera(); } @override @@ -352,10 +547,142 @@ class _ChannelMessagePathMapScreenState @override void dispose() { + _playback.dispose(); _mapController.dispose(); super.dispose(); } + /// Builds a renderable [DisplayPath] for one observed route, oriented in + /// the direction the packet traveled (sender first, receiver last). + DisplayPath? _buildDisplayPath({ + required int index, + required bool isPrimary, + required Uint8List orientedBytes, + required List<_PathHop> hops, + required MeshCoreConnector connector, + }) { + final l10n = context.l10n; + final selfLat = connector.selfLatitude; + final selfLon = connector.selfLongitude; + + final points = []; + final labels = []; + final confirmed = []; + final rowIdx = []; + final gapBefore = []; + var pendingGap = false; + var locatedHops = 0; + + void addSelf() { + if (selfLat == null || selfLon == null) return; + points.add(LatLng(selfLat, selfLon)); + labels.add(l10n.pathTrace_you); + confirmed.add(true); + rowIdx.add(-1); + gapBefore.add(pendingGap); + pendingGap = false; + } + + final selfFirst = widget.message.isOutgoing; + if (selfFirst) addSelf(); + for (var i = 0; i < hops.length; i++) { + final hop = hops[i]; + if (!hop.hasLocation) { + pendingGap = true; + continue; + } + locatedHops++; + points.add(hop.position!); + labels.add(hop.contact?.name ?? _formatPrefix(hop.prefix)); + confirmed.add(true); + rowIdx.add(i); + gapBefore.add(pendingGap); + pendingGap = false; + } + if (!selfFirst) addSelf(); + + if (points.length < 2) return null; + + final segmentEstimated = []; + final rowForSegment = []; + for (var i = 0; i < points.length - 1; i++) { + segmentEstimated.add(gapBefore[i + 1]); + final dest = rowIdx[i + 1]; + rowForSegment.add(dest >= 0 ? dest : (rowIdx[i] >= 0 ? rowIdx[i] : 0)); + } + + return DisplayPath( + id: 'op-$index', + label: isPrimary ? l10n.pathMap_primary : l10n.pathMap_alternate(index), + color: isPrimary + ? kPrimaryPathColor + : kAlternatePathColors[(index - 1) % kAlternatePathColors.length], + isPrimary: isPrimary, + hopBytes: List.from(orientedBytes), + points: points, + pointLabels: labels, + pointConfirmed: confirmed, + segmentEstimated: segmentEstimated, + rowForSegment: rowForSegment, + totalTransmissions: hops.length, + hasTargetEndpoint: false, + gpsConfirmedHops: locatedHops, + unresolvedHops: hops.length - locatedHops, + distanceMeters: getPathDistanceMeters(points), + record: null, + ); + } + + /// Updates the playback path after this frame, but only when the selected + /// path's geometry actually changed, so rebuilds don't reset a running + /// animation. + void _schedulePlaybackSync(DisplayPath? selected) { + final points = selected?.points ?? const []; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (points.length == _playback.points.length) { + var same = true; + for (var i = 0; i < points.length; i++) { + if (points[i] != _playback.points[i]) { + same = false; + break; + } + } + if (same) return; + } + _playback.setPath(points); + }); + } + + void _selectEntry(_ObservedPathEntry entry) { + setState(() { + _selectedPath = entry.observedBytes; + _hiddenPathIds.remove(entry.display.id); + _focusedHopIndex = null; + }); + } + + void _togglePathVisibility( + DisplayPath path, + List<_ObservedPathEntry> entries, + DisplayPath? selected, + ) { + setState(() { + if (!_hiddenPathIds.remove(path.id)) { + _hiddenPathIds.add(path.id); + if (path.id == selected?.id) { + final visible = entries.where( + (e) => !_hiddenPathIds.contains(e.display.id), + ); + if (visible.isNotEmpty) { + _selectedPath = visible.first.observedBytes; + _focusedHopIndex = null; + } + } + } + }); + } + bool _isDesktopPlatform(TargetPlatform platform) { return platform == TargetPlatform.linux || platform == TargetPlatform.windows || @@ -460,6 +787,7 @@ class _ChannelMessagePathMapScreenState final settings = context.watch().settings; final isImperial = settings.unitSystem == UnitSystem.imperial; final tileCache = context.read(); + final mapScheme = Theme.of(context).colorScheme; final primaryPath = _selectPrimaryPath( widget.message.pathBytes, widget.message.pathVariants, @@ -475,15 +803,57 @@ class _ChannelMessagePathMapScreenState primaryPath, ); - final selectedPath = - ((!widget.message.isOutgoing && !widget.channelMessage) || - (widget.message.isOutgoing && widget.channelMessage)) - ? Uint8List.fromList(selectedPathTmp.reversed.toList()) - : selectedPathTmp; + final selectedPath = _orientPath(selectedPathTmp); - final selectedIndex = _indexForPath(selectedPath, observedPaths); + // Match on the unoriented bytes — observedPaths stores them as + // recorded, while selectedPath may be reversed for display. + final selectedIndex = _indexForPath(selectedPathTmp, observedPaths); final hops = _buildPathHops(selectedPath, connector, context.l10n); + // Renderable paths for the animation and combined view. + final entries = <_ObservedPathEntry>[]; + for (var i = 0; i < observedPaths.length; i++) { + final oriented = _orientPath(observedPaths[i].pathBytes); + final pathHops = i == selectedIndex + ? hops + : _buildPathHops(oriented, connector, context.l10n); + final display = _buildDisplayPath( + index: i, + isPrimary: observedPaths[i].isPrimary, + orientedBytes: oriented, + hops: pathHops, + connector: connector, + ); + if (display != null) { + entries.add( + _ObservedPathEntry( + index: i, + observedBytes: observedPaths[i].pathBytes, + display: display, + hops: pathHops, + ), + ); + } + } + final effectiveMode = entries.length > 1 + ? _viewMode + : PathViewMode.single; + _ObservedPathEntry? selectedEntry; + for (final entry in entries) { + if (entry.index == selectedIndex) { + selectedEntry = entry; + break; + } + } + final selectedDisplay = selectedEntry?.display; + final visibleEntries = effectiveMode == PathViewMode.single + ? [?selectedEntry] + : entries + .where((e) => !_hiddenPathIds.contains(e.display.id)) + .toList(); + final visibleDisplays = visibleEntries.map((e) => e.display).toList(); + _schedulePlaybackSync(selectedDisplay); + final points = []; if ((widget.message.isOutgoing && !widget.channelMessage) || @@ -507,7 +877,7 @@ class _ChannelMessagePathMapScreenState Polyline( points: points, strokeWidth: 4, - color: Colors.blueAccent, + color: MeshPalette.blue, ), ] : []; @@ -564,10 +934,16 @@ class _ChannelMessagePathMapScreenState : const KeyboardOptions.disabled(), ), onPositionChanged: (camera, hasGesture) { + if (!mounted) return; + // A manual pan/zoom releases the follow lock. + if (hasGesture && _followPacket) { + setState(() { + _followPacket = false; + }); + } final shouldShow = camera.zoom >= _labelZoomThreshold; if (!_didReceivePositionUpdate || shouldShow != _showNodeLabels) { - if (!mounted) return; setState(() { _didReceivePositionUpdate = true; _showNodeLabels = shouldShow; @@ -576,20 +952,65 @@ class _ChannelMessagePathMapScreenState }, ), children: [ - TileLayer( - urlTemplate: kMapTileUrlTemplate, - tileProvider: tileCache.tileProvider, - userAgentPackageName: - MapTileCacheService.userAgentPackageName, - maxZoom: 19, + ThemedMapTileLayer(tileCache: tileCache), + AnimatedBuilder( + animation: _playback, + builder: (context, _) { + List lines; + if (visibleDisplays.isEmpty) { + lines = polylines; + } else { + final animating = + _animationEnabled && + _playback.started && + _playback.hasPath; + lines = buildMultiPathPolylines( + visible: visibleDisplays, + selected: selectedDisplay, + combined: + effectiveMode == PathViewMode.combined, + animating: animating, + ); + if (animating && selectedDisplay != null) { + lines.addAll( + buildPacketTrailPolylines( + _playback, + selectedDisplay.color, + ), + ); + } + } + if (lines.isEmpty) return const SizedBox.shrink(); + return PolylineLayer(polylines: lines); + }, ), - if (polylines.isNotEmpty) - PolylineLayer(polylines: polylines), - MarkerLayer( - markers: _buildHopMarkers( - hops, - showLabels: _showNodeLabels, + if (effectiveMode == PathViewMode.combined) + MarkerLayer( + markers: _buildCombinedHopMarkers( + visibleEntries, + showLabels: _showNodeLabels, + ), + ) + else + MarkerLayer( + markers: _buildHopMarkers( + hops, + showLabels: _showNodeLabels, + ), ), + AnimatedBuilder( + animation: _playback, + builder: (context, _) { + if (!_animationEnabled || selectedDisplay == null) { + return const SizedBox.shrink(); + } + final markers = buildPacketMarkers( + _playback, + selectedDisplay.color, + ); + if (markers.isEmpty) return const SizedBox.shrink(); + return MarkerLayer(markers: markers); + }, ), ], ), @@ -599,30 +1020,46 @@ class _ChannelMessagePathMapScreenState initialZoom: initialZoom, bounds: bounds, ), - if (observedPaths.length > 1) - _buildPathSelector(context, observedPaths, selectedIndex, ( - index, - ) { - setState(() { - _selectedPath = observedPaths[index].pathBytes; - _focusedHopIndex = null; - }); - }), + if (entries.length > 1) + PathViewModeToggle( + mode: effectiveMode, + onChanged: (mode) => setState(() => _viewMode = mode), + ), + if (observedPaths.length > 1 && + effectiveMode == PathViewMode.single) + _buildPathSelector( + context, + observedPaths, + selectedIndex, + (index) { + setState(() { + _selectedPath = observedPaths[index].pathBytes; + _focusedHopIndex = null; + }); + }, + topOffset: entries.length > 1 ? 60.0 : 16.0, + ), if (points.isEmpty) Center( - child: Card( - color: Theme.of( - context, - ).colorScheme.surface.withValues(alpha: 0.9), - child: Padding( - padding: EdgeInsets.all(12), - child: Text( - context.l10n.channelPath_noRepeaterLocations, - ), + child: Container( + margin: const EdgeInsets.all(24), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: mapScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: mapScheme.outlineVariant), ), + child: Text(context.l10n.channelPath_noRepeaterLocations), ), ), - _buildLegendCard(context, hops, isImperial), + _buildLegendCard( + context, + hops, + isImperial, + entries: entries, + selectedEntry: selectedEntry, + effectiveMode: effectiveMode, + ), ], ), ), @@ -635,8 +1072,9 @@ class _ChannelMessagePathMapScreenState BuildContext context, List<_ObservedPath> paths, int selectedIndex, - ValueChanged onSelected, - ) { + ValueChanged onSelected, { + double topOffset = 16, + }) { final l10n = context.l10n; final selectedPath = paths[selectedIndex]; final label = selectedPath.isPrimary @@ -645,7 +1083,7 @@ class _ChannelMessagePathMapScreenState return Positioned( left: 16, right: 16, - top: 16, + top: topOffset, child: SafeArea( child: Card( child: Padding( @@ -715,7 +1153,7 @@ class _ChannelMessagePathMapScreenState width: 35, height: 35, decoration: BoxDecoration( - color: Colors.green, + color: MeshPalette.blue, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: [ @@ -749,40 +1187,163 @@ class _ChannelMessagePathMapScreenState } } + markers.addAll(_buildSelfMarkers(showLabels: showLabels)); + + return markers; + } + + List _buildSelfMarkers({required bool showLabels}) { final selfLat = context.read().selfLatitude; final selfLon = context.read().selfLongitude; - if (selfLat != null && selfLon != null) { - final selfPoint = LatLng(selfLat, selfLon); + if (selfLat == null || selfLon == null) return const []; + final markers = []; + final selfPoint = LatLng(selfLat, selfLon); + markers.add( + Marker( + point: selfPoint, + width: 48, + height: 48, + child: Center( + child: Container( + width: 35, + height: 35, + decoration: BoxDecoration( + color: MeshPalette.signal, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: Text( + context.l10n.pathTrace_you, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ), + ), + ); + if (showLabels) { + markers.add( + _buildNodeLabelMarker( + point: selfPoint, + label: context.l10n.pathTrace_you, + ), + ); + } + return markers; + } + + /// Markers for the union of located hops across all visible paths, with a + /// badge on repeaters used by more than one path. + List _buildCombinedHopMarkers( + List<_ObservedPathEntry> visibleEntries, { + required bool showLabels, + }) { + final markers = []; + + final nodes = {}; + for (final entry in visibleEntries) { + final seenInPath = {}; + for (final hop in entry.hops) { + if (!hop.hasLocation) continue; + final key = + '${hop.prefix}|${hop.position!.latitude.toStringAsFixed(5)},' + '${hop.position!.longitude.toStringAsFixed(5)}'; + if (!seenInPath.add(key)) continue; + nodes.putIfAbsent(key, () => _SharedNode(hop)).paths.add(entry.display); + } + } + + for (final node in nodes.values) { + final hop = node.hop; + final point = hop.position!; + final label = _formatPrefix(hop.prefix); + final shared = node.paths.length > 1; + markers.add( Marker( - point: selfPoint, + point: point, width: 48, height: 48, - child: Center( - child: Container( - width: 35, - height: 35, - decoration: BoxDecoration( - color: Colors.teal, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), + child: GestureDetector( + onTap: () => showSharedNodeSheet( + context, + title: '$label: ${_resolveName(hop.contact, context.l10n)}', + paths: node.paths, + onSelect: (display) { + for (final entry in visibleEntries) { + if (entry.display.id == display.id) { + _selectEntry(entry); + break; + } + } + }, + ), + child: Stack( alignment: Alignment.center, - child: Text( - context.l10n.pathTrace_you, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, + children: [ + Container( + width: 35, + height: 35, + decoration: BoxDecoration( + color: MeshPalette.blue, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: shared ? 2.5 : 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + alignment: Alignment.center, + child: Text( + label, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), ), - ), + if (shared) + Positioned( + top: 0, + right: 0, + child: Container( + width: 17, + height: 17, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: MeshPalette.bg1, + border: Border.all(color: MeshPalette.line3), + ), + alignment: Alignment.center, + child: Text( + '${node.paths.length}', + style: MeshTheme.mono( + fontSize: 9, + fontWeight: FontWeight.w700, + color: MeshPalette.ink, + ), + ), + ), + ), + ], ), ), ), @@ -790,16 +1351,26 @@ class _ChannelMessagePathMapScreenState if (showLabels) { markers.add( _buildNodeLabelMarker( - point: selfPoint, - label: context.l10n.pathTrace_you, + point: point, + label: hop.contact?.name ?? label, ), ); } } + markers.addAll(_buildSelfMarkers(showLabels: showLabels)); + return markers; } + /// Orients recorded path bytes in the direction the packet traveled. + Uint8List _orientPath(Uint8List bytes) { + final reverse = + (!widget.message.isOutgoing && !widget.channelMessage) || + (widget.message.isOutgoing && widget.channelMessage); + return reverse ? Uint8List.fromList(bytes.reversed.toList()) : bytes; + } + Marker _buildNodeLabelMarker({required LatLng point, required String label}) { return Marker( point: point, @@ -835,21 +1406,38 @@ class _ChannelMessagePathMapScreenState ); } - Widget _colorDot(Color color) => Container( - width: 10, - height: 10, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ); - Widget _buildLegendCard( BuildContext context, List<_PathHop> hops, - bool isImperial, - ) { + bool isImperial, { + required List<_ObservedPathEntry> entries, + required _ObservedPathEntry? selectedEntry, + required PathViewMode effectiveMode, + }) { final l10n = context.l10n; - final maxHeight = MediaQuery.of(context).size.height * 0.35; - final estimatedHeight = 72.0 + (hops.length * 56.0); - final cardHeight = max(96.0, min(maxHeight, estimatedHeight)); + final combined = effectiveMode == PathViewMode.combined; + final selectedDisplay = selectedEntry?.display; + final maxHeight = + MediaQuery.of(context).size.height * (combined ? 0.45 : 0.35); + + double cardHeight; + if (_panelCollapsed) { + cardHeight = 128; + } else { + final summaryHeight = combined ? 34.0 + entries.length * 36.0 : 0; + final estimatedHeight = 132.0 + summaryHeight + hops.length * 56.0; + cardHeight = max(176.0, min(maxHeight, estimatedHeight)); + } + + final hopUseCount = {}; + if (combined) { + for (final entry in entries) { + if (_hiddenPathIds.contains(entry.display.id)) continue; + for (final prefix in entry.hops.map((h) => h.prefix).toSet()) { + hopUseCount.update(prefix, (v) => v + 1, ifAbsent: () => 1); + } + } + } return Positioned( left: 16, @@ -857,80 +1445,269 @@ class _ChannelMessagePathMapScreenState bottom: 16, child: SizedBox( height: cardHeight, - child: Card( + child: Container( + decoration: BoxDecoration( + color: MeshPalette.bg1, + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: MeshPalette.line2), + ), + clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.all(12), - child: Column( + padding: const EdgeInsets.fromLTRB(12, 8, 4, 0), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistance, isImperial: isImperial)}', - style: const TextStyle(fontWeight: FontWeight.w600), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.channelPath_repeaterHops, + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + formatDistance( + selectedDisplay?.distanceMeters ?? + _pathDistance, + isImperial: isImperial, + ), + style: MeshTheme.mono( + fontSize: 12, + color: MeshPalette.ink2, + ), + ), + ], + ), + const SizedBox(height: 4), + PathMiniLegend( + combined: combined, + showInferred: false, + ), + ], + ), ), - const SizedBox(height: 6), - Row( - children: [ - _colorDot(Colors.green), - const SizedBox(width: 4), - Text( - l10n.pathTrace_legendGpsConfirmed, - style: const TextStyle(fontSize: 11), - ), - ], + IconButton( + visualDensity: VisualDensity.compact, + icon: Icon( + _panelCollapsed + ? Icons.expand_less + : Icons.expand_more, + size: 20, + ), + tooltip: _panelCollapsed + ? l10n.pathMap_expandPanel + : l10n.pathMap_collapsePanel, + onPressed: () => setState( + () => _panelCollapsed = !_panelCollapsed, + ), ), ], ), ), - const Divider(height: 1), - Expanded( - child: hops.isEmpty - ? Center( - child: Text(l10n.channelPath_noHopDetailsAvailable), - ) - : ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: hops.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final hop = hops[index]; - final isFocused = _focusedHopIndex == hop.index; - return ListTile( - dense: true, - enabled: hop.hasLocation, - selected: isFocused, - selectedTileColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.12), - onTap: hop.hasLocation - ? () => _onHopTapped(hop) - : null, - leading: CircleAvatar( - radius: 14, - child: Text( - hop.index.toString(), - style: const TextStyle(fontSize: 12), - ), - ), - title: Text(hop.displayLabel), - subtitle: Text( - hop.hasLocation - ? '${hop.position!.latitude.toStringAsFixed(5)}, ' - '${hop.position!.longitude.toStringAsFixed(5)}' - : l10n.channelPath_noLocationData, - ), - ); - }, - ), + PathAnimationControls( + playback: _playback, + selected: selectedDisplay, + animationEnabled: _animationEnabled, + onToggleAnimation: () => setState(() { + _animationEnabled = !_animationEnabled; + if (!_animationEnabled) _playback.stop(); + }), + followEnabled: _followPacket, + onToggleFollow: _toggleFollowPacket, ), + if (!_panelCollapsed) ...[ + if (selectedDisplay != null && + selectedDisplay.unresolvedHops > 0) + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), + child: Text( + l10n.pathMap_partialAnimation( + selectedDisplay.unresolvedHops, + ), + style: TextStyle(fontSize: 10.5, color: MeshPalette.warn), + ), + ), + if (combined) + PathSummaryList( + paths: entries.map((e) => e.display).toList(), + selectedId: selectedDisplay?.id ?? '', + hiddenIds: _hiddenPathIds, + isImperial: isImperial, + onSelect: (display) { + for (final entry in entries) { + if (entry.display.id == display.id) { + _selectEntry(entry); + break; + } + } + }, + onToggleVisibility: (display) => _togglePathVisibility( + display, + entries, + selectedDisplay, + ), + onShowAll: () => setState(_hiddenPathIds.clear), + ), + const Divider(height: 1), + Expanded( + child: _buildHopListView( + hops, + selectedDisplay, + hopUseCount, + ), + ), + ], ], ), ), ), ); } + + Widget _buildHopListView( + List<_PathHop> hops, + DisplayPath? selectedDisplay, + Map hopUseCount, + ) { + final l10n = context.l10n; + if (hops.isEmpty) { + return Center(child: Text(l10n.channelPath_noHopDetailsAvailable)); + } + return ValueListenableBuilder( + valueListenable: _playback.activeSegment, + builder: (context, activeSegment, _) { + int highlightRow = -1; + if (_animationEnabled && + selectedDisplay != null && + activeSegment >= 0 && + activeSegment < selectedDisplay.rowForSegment.length) { + highlightRow = selectedDisplay.rowForSegment[activeSegment]; + } + final highlightColor = (selectedDisplay?.color ?? MeshPalette.blue) + .withValues(alpha: 0.14); + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: hops.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final hop = hops[index]; + final isFocused = _focusedHopIndex == hop.index; + final sharedCount = hopUseCount[hop.prefix] ?? 0; + return InkWell( + onTap: hop.hasLocation ? () => _onHopTapped(hop) : null, + child: Container( + color: index == highlightRow + ? highlightColor + : isFocused + ? MeshPalette.blueBg + : Colors.transparent, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: MeshPalette.blueDim.withValues( + alpha: 0.3, + ), + shape: BoxShape.circle, + border: Border.all( + color: MeshPalette.blueDim.withValues( + alpha: 0.5, + ), + ), + ), + alignment: Alignment.center, + child: Text( + hop.index.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + hop.displayLabel, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + [ + hop.hasLocation + ? '${hop.position!.latitude.toStringAsFixed(5)}, ' + '${hop.position!.longitude.toStringAsFixed(5)}' + : context + .l10n + .channelPath_noLocationData, + if (sharedCount > 1) + context.l10n.pathMap_sharedNodeCount( + sharedCount, + ), + ].join(' · '), + style: MeshTheme.mono( + fontSize: 10, + color: MeshPalette.ink3, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } +} + +/// One observed route paired with its renderable form and resolved hops. +class _ObservedPathEntry { + final int index; + final Uint8List observedBytes; + final DisplayPath display; + final List<_PathHop> hops; + + const _ObservedPathEntry({ + required this.index, + required this.observedBytes, + required this.display, + required this.hops, + }); +} + +/// A located hop shared across one or more visible paths. +class _SharedNode { + final _PathHop hop; + final List paths = []; + + _SharedNode(this.hop); } class _PathHop { diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 36e82049..080eef8e 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:math'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:meshcore_open/storage/channel_message_store.dart'; import 'package:meshcore_open/utils/platform_info.dart'; import 'package:meshcore_open/widgets/app_bar.dart'; @@ -16,11 +16,13 @@ import '../services/ui_view_state_service.dart'; import '../models/channel.dart'; import '../models/community.dart'; import '../storage/community_store.dart'; +import '../theme/mesh_theme.dart'; import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/route_transitions.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; +import '../widgets/mesh_ui.dart'; import '../widgets/qr_code_display.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/sync_progress_overlay.dart'; @@ -82,6 +84,14 @@ class _ChannelsScreenState extends State super.dispose(); } + String _relativeTime(DateTime t) { + final diff = DateTime.now().difference(t); + if (diff.inMinutes < 1) return 'now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m'; + if (diff.inHours < 24) return '${diff.inHours}h'; + return '${diff.inDays}d'; + } + @override Widget build(BuildContext context) { final connector = context.watch(); @@ -107,16 +117,19 @@ class _ChannelsScreenState extends State bottom: const SyncProgressAppBarBottom(), actions: [ PopupMenuButton( - itemBuilder: (context) => [ + // onTap handlers run after the menu route pops, so they must + // capture the screen's context — not the itemBuilder's menu + // context, which is deactivated by then. + itemBuilder: (menuContext) => [ PopupMenuItem( child: Row( children: [ Icon( Icons.logout, - color: Theme.of(context).colorScheme.error, + color: Theme.of(menuContext).colorScheme.error, ), const SizedBox(width: 8), - Text(context.l10n.common_disconnect), + Text(menuContext.l10n.common_disconnect), ], ), onTap: () => _disconnect(context), @@ -126,7 +139,7 @@ class _ChannelsScreenState extends State children: [ const Icon(Icons.groups), const SizedBox(width: 8), - Text(context.l10n.community_manageCommunities), + Text(menuContext.l10n.community_manageCommunities), ], ), onTap: () => _showManageCommunitiesDialog(context), @@ -136,7 +149,7 @@ class _ChannelsScreenState extends State children: [ const Icon(Icons.settings), const SizedBox(width: 8), - Text(context.l10n.settings_title), + Text(menuContext.l10n.settings_title), ], ), onTap: () => Navigator.push( @@ -219,9 +232,6 @@ class _ChannelsScreenState extends State _buildFilterButton(viewState), ], ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, @@ -264,8 +274,8 @@ class _ChannelsScreenState extends State viewState.channelsSearchText.isEmpty) ? ReorderableListView.builder( padding: const EdgeInsets.only( - left: 16, - right: 16, + left: 0, + right: 0, top: 8, bottom: 88, ), @@ -294,13 +304,14 @@ class _ChannelsScreenState extends State channel, showDragHandle: true, dragIndex: index, + listIndex: index, ); }, ) : ListView.builder( padding: const EdgeInsets.only( - left: 16, - right: 16, + left: 0, + right: 0, top: 8, bottom: 88, ), @@ -312,6 +323,7 @@ class _ChannelsScreenState extends State connector, channelMessageStore, channel, + listIndex: index, ); }, ), @@ -346,16 +358,17 @@ class _ChannelsScreenState extends State Channel channel, { bool showDragHandle = false, int? dragIndex, + int listIndex = 0, }) { final unreadCount = connector.getUnreadCountForChannel(channel); final isMuted = context.watch().isChannelMuted( channel.name, ); + final scheme = Theme.of(context).colorScheme; // Determine icon and colors based on channel type IconData icon; Color iconColor; - Color bgColor; final ChannelType channelType = Channel.getChannelType( channel, _communityIndex, @@ -364,131 +377,194 @@ class _ChannelsScreenState extends State switch (channelType) { case ChannelType.communityPublic: icon = Icons.groups; - iconColor = Colors.purple; - bgColor = Colors.purple.withValues(alpha: 0.2); + iconColor = MeshPalette.magenta; case ChannelType.communityHashtag: - icon = Icons.tag; - iconColor = Colors.purple; - bgColor = Colors.purple.withValues(alpha: 0.2); + icon = Icons.groups; + iconColor = MeshPalette.magenta; case ChannelType.public: icon = Icons.public; - iconColor = Colors.green; - bgColor = Colors.green.withValues(alpha: 0.2); + iconColor = MeshPalette.signal; case ChannelType.hashtag: icon = Icons.tag; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); + iconColor = MeshPalette.blue; case ChannelType.private: icon = Icons.lock; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); + iconColor = MeshPalette.blue; } - return Card( - key: ValueKey('channel_${channel.index}'), - margin: const EdgeInsets.only(bottom: 12), - child: GestureDetector( - onSecondaryTapUp: PlatformInfo.isDesktop - ? (_) => _showChannelActions( - context, - connector, - channelMessageStore, - channel, - ) - : null, - child: ListTile( - dense: true, - minVerticalPadding: 14, - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - visualDensity: const VisualDensity(vertical: -2), - leading: Stack( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: CircleAvatar( - backgroundColor: bgColor, - child: Icon(icon, color: iconColor), - ), + // Last message preview + final messages = connector.getChannelMessages(channel); + final lastMessage = messages.isNotEmpty ? messages.last : null; + final lastPreview = lastMessage?.text ?? ''; + final lastTime = lastMessage?.timestamp; + + final channelLabel = channel.name.isEmpty + ? context.l10n.channels_channelIndex(channel.index) + : channel.name; + + return ListEntrance( + key: ValueKey('channel_entrance_${channel.index}'), + index: dragIndex ?? listIndex, + child: MeshCard( + key: ValueKey('channel_${channel.index}'), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + onTap: () { + HapticFeedback.selectionClick(); + final unread = connector.getUnreadCountForChannelIndex(channel.index); + connector.markChannelRead(channel.index); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChannelChatScreen( + channel: channel, + initialUnreadCount: unread, ), - if (isCommunityChannel) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 14, - height: 14, - decoration: BoxDecoration( - color: Colors.purple, - shape: BoxShape.circle, - border: Border.all( - color: Theme.of(context).cardColor, - width: 2, + ), + ); + }, + onLongPress: () => _showChannelActions( + this.context, + connector, + channelMessageStore, + channel, + ), + child: GestureDetector( + onSecondaryTapUp: PlatformInfo.isDesktop + ? (_) => _showChannelActions( + this.context, + connector, + channelMessageStore, + channel, + ) + : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Leading avatar with optional community badge + Stack( + clipBehavior: Clip.none, + children: [ + AvatarCircle( + name: channelLabel, + size: 42, + color: iconColor, + icon: icon, + ), + if (isCommunityChannel) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: MeshPalette.magenta, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.surfaceContainerLow, + width: 2, + ), + ), + child: const Icon( + Icons.people, + size: 8, + color: Colors.white, + ), ), ), - child: const Icon( - Icons.people, - size: 8, - color: Colors.white, + ], + ), + const SizedBox(width: 12), + // Title + subtitle + ch chip + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + channelLabel, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + StatusChip( + label: 'CH ${channel.index}', + color: MeshPalette.blue, + fontSize: 10, + ), + ], ), + if (lastPreview.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + lastPreview, + style: MeshTheme.mono( + fontSize: 11.5, + color: scheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + const SizedBox(width: 8), + // Right side: time + unread badge + muted + drag handle + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (lastTime != null) + Text( + _relativeTime(lastTime), + style: MeshTheme.mono( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isMuted) ...[ + Icon( + Icons.notifications_off, + size: 14, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + ], + if (unreadCount > 0) + UnreadBadge(count: unreadCount), + ], ), - ), - ], - ), - title: Text( - channel.name.isEmpty - ? context.l10n.channels_channelIndex(channel.index) - : channel.name, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isMuted) ...[ - Icon( - Icons.notifications_off, - size: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + ], + ), + if (showDragHandle && dragIndex != null) ...[ const SizedBox(width: 4), - ], - if (unreadCount > 0) ...[ - UnreadBadge(count: unreadCount), - const SizedBox(width: 4), - ], - if (showDragHandle && dragIndex != null) ReorderableDragStartListener( index: dragIndex, child: Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(8), child: Icon( Icons.drag_handle, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: scheme.onSurfaceVariant, ), ), ), + ], ], ), - onTap: () { - final unread = connector.getUnreadCountForChannelIndex( - channel.index, - ); - connector.markChannelRead(channel.index); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChannelChatScreen( - channel: channel, - initialUnreadCount: unread, - ), - ), - ); - }, - onLongPress: () => _showChannelActions( - context, - connector, - channelMessageStore, - channel, - ), ), ), ); @@ -512,7 +588,7 @@ class _ChannelsScreenState extends State children: [ ListTile( leading: const Icon(Icons.edit_outlined), - title: Text(context.l10n.channels_editChannel), + title: Text(sheetContext.l10n.channels_editChannel), onTap: () async { Navigator.pop(sheetContext); await Future.delayed(const Duration(milliseconds: 100)); @@ -529,8 +605,8 @@ class _ChannelsScreenState extends State ), title: Text( isMuted - ? context.l10n.channels_unmuteChannel - : context.l10n.channels_muteChannel, + ? sheetContext.l10n.channels_unmuteChannel + : sheetContext.l10n.channels_muteChannel, ), onTap: () async { Navigator.pop(sheetContext); @@ -547,7 +623,7 @@ class _ChannelsScreenState extends State color: Theme.of(sheetContext).colorScheme.error, ), title: Text( - context.l10n.channels_deleteChannel, + sheetContext.l10n.channels_deleteChannel, style: TextStyle( color: Theme.of(sheetContext).colorScheme.error, ), @@ -557,7 +633,7 @@ class _ChannelsScreenState extends State await Future.delayed(const Duration(milliseconds: 100)); if (parentContext.mounted) { _confirmDeleteChannel( - context, + parentContext, connector, channelMessageStore, channel, @@ -714,11 +790,11 @@ class _ChannelsScreenState extends State _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; - showDialog( - context: context, - builder: (dialogContext) => StatefulBuilder( - builder: (dialogContext, setDialogState) { - Widget buildOptionTile({ + showMeshSheet( + context, + builder: (sheetContext) => StatefulBuilder( + builder: (sheetContext, setSheetState) { + Widget buildOptionCard({ required int optionIndex, required IconData icon, required String title, @@ -726,51 +802,19 @@ class _ChannelsScreenState extends State bool enabled = true, }) { final isSelected = selectedOption == optionIndex; - return ListTile( - leading: CircleAvatar( - backgroundColor: enabled - ? (isSelected - ? Theme.of(dialogContext).colorScheme.primaryContainer - : null) - : Theme.of( - dialogContext, - ).colorScheme.onSurface.withValues(alpha: 0.12), - child: Icon( - icon, - color: enabled - ? (isSelected - ? Theme.of(dialogContext).colorScheme.primary - : null) - : Theme.of( - dialogContext, - ).colorScheme.onSurface.withValues(alpha: 0.38), - ), - ), - title: Text( - title, - style: TextStyle( - color: enabled - ? null - : Theme.of( - dialogContext, - ).colorScheme.onSurface.withValues(alpha: 0.38), - ), - ), - subtitle: Text( - subtitle, - style: TextStyle( - color: enabled - ? null - : Theme.of( - dialogContext, - ).colorScheme.onSurface.withValues(alpha: 0.38), - ), - ), - trailing: enabled ? const Icon(Icons.chevron_right) : null, - selected: isSelected, + final cardScheme = Theme.of(sheetContext).colorScheme; + return MeshCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + borderColor: isSelected && enabled + ? MeshPalette.blueLine + : null, + color: isSelected && enabled + ? MeshPalette.blueBg + : null, onTap: enabled ? () { - setDialogState(() { + setSheetState(() { selectedOption = optionIndex; nameController.clear(); pskController.clear(); @@ -778,6 +822,49 @@ class _ChannelsScreenState extends State }); } : null, + child: Row( + children: [ + AvatarCircle( + name: title, + size: 38, + color: enabled + ? (isSelected ? MeshPalette.blue : cardScheme.onSurfaceVariant) + : cardScheme.outline, + icon: icon, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: enabled ? null : cardScheme.outline, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: Theme.of(sheetContext).textTheme.bodySmall?.copyWith( + color: enabled ? cardScheme.onSurfaceVariant : cardScheme.outline, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (enabled) + Icon( + Icons.chevron_right, + color: isSelected ? MeshPalette.blue : cardScheme.onSurfaceVariant, + size: 20, + ), + ], + ), ); } @@ -796,7 +883,7 @@ class _ChannelsScreenState extends State child: TextField( controller: nameController, decoration: InputDecoration( - labelText: dialogContext.l10n.channels_channelName, + labelText: sheetContext.l10n.channels_channelName, border: const OutlineInputBorder(), ), maxLength: 31, @@ -814,7 +901,7 @@ class _ChannelsScreenState extends State showDismissibleSnackBar( context, content: Text( - dialogContext + sheetContext .l10n .channels_enterChannelName, ), @@ -826,7 +913,7 @@ class _ChannelsScreenState extends State for (int i = 0; i < 16; i++) { psk[i] = random.nextInt(256); } - Navigator.pop(dialogContext); + Navigator.pop(sheetContext); await connector.setChannel( nextIndex, name, @@ -844,12 +931,13 @@ class _ChannelsScreenState extends State ); } }, - child: Text(dialogContext.l10n.common_create), + child: Text(sheetContext.l10n.common_create), ), ), ], ), ), + const SizedBox(height: 8), ], ); @@ -864,7 +952,7 @@ class _ChannelsScreenState extends State child: TextField( controller: nameController, decoration: InputDecoration( - labelText: dialogContext.l10n.channels_channelName, + labelText: sheetContext.l10n.channels_channelName, border: const OutlineInputBorder(), ), maxLength: 31, @@ -878,7 +966,7 @@ class _ChannelsScreenState extends State child: TextField( controller: pskController, decoration: InputDecoration( - labelText: dialogContext.l10n.channels_pskHex, + labelText: sheetContext.l10n.channels_pskHex, border: const OutlineInputBorder(), ), ), @@ -896,7 +984,7 @@ class _ChannelsScreenState extends State showDismissibleSnackBar( context, content: Text( - dialogContext + sheetContext .l10n .channels_enterChannelName, ), @@ -910,14 +998,14 @@ class _ChannelsScreenState extends State showDismissibleSnackBar( context, content: Text( - dialogContext + sheetContext .l10n .channels_pskMustBe32Hex, ), ); return; } - Navigator.pop(dialogContext); + Navigator.pop(sheetContext); connector.setChannel(nextIndex, name, psk); if (context.mounted) { showDismissibleSnackBar( @@ -928,12 +1016,13 @@ class _ChannelsScreenState extends State ); } }, - child: Text(dialogContext.l10n.common_add), + child: Text(sheetContext.l10n.common_add), ), ), ], ), ), + const SizedBox(height: 8), ], ); @@ -951,7 +1040,7 @@ class _ChannelsScreenState extends State final psk = Channel.parsePskHex( Channel.publicChannelPsk, ); - Navigator.pop(dialogContext); + Navigator.pop(sheetContext); connector.setChannel( nextIndex, context.l10n.channels_public, @@ -966,7 +1055,7 @@ class _ChannelsScreenState extends State ); } }, - child: Text(dialogContext.l10n.common_add), + child: Text(sheetContext.l10n.common_add), ), ), ], @@ -980,7 +1069,7 @@ class _ChannelsScreenState extends State if (_communities.isNotEmpty) ...[ RadioGroup( groupValue: isRegularHashtag, - onChanged: (v) => setDialogState(() { + onChanged: (v) => setSheetState(() { if (v == null) return; isRegularHashtag = v; if (isRegularHashtag) { @@ -995,20 +1084,20 @@ class _ChannelsScreenState extends State RadioListTile( value: true, title: Text( - dialogContext.l10n.community_regularHashtag, + sheetContext.l10n.community_regularHashtag, ), subtitle: Text( - dialogContext.l10n.community_regularHashtagDesc, + sheetContext.l10n.community_regularHashtagDesc, ), dense: true, ), RadioListTile( value: false, title: Text( - dialogContext.l10n.community_communityHashtag, + sheetContext.l10n.community_communityHashtag, ), subtitle: Text( - dialogContext + sheetContext .l10n .community_communityHashtagDesc, ), @@ -1036,10 +1125,10 @@ class _ChannelsScreenState extends State ) .toList(), onChanged: (c) => - setDialogState(() => selectedCommunity = c), + setSheetState(() => selectedCommunity = c), decoration: InputDecoration( labelText: - dialogContext.l10n.community_selectCommunity, + sheetContext.l10n.community_selectCommunity, border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.groups), ), @@ -1054,8 +1143,8 @@ class _ChannelsScreenState extends State child: TextField( controller: hashtagController, decoration: InputDecoration( - labelText: dialogContext.l10n.channels_enterHashtag, - hintText: dialogContext.l10n.channels_hashtagHint, + labelText: sheetContext.l10n.channels_enterHashtag, + hintText: sheetContext.l10n.channels_hashtagHint, border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.tag), ), @@ -1067,11 +1156,11 @@ class _ChannelsScreenState extends State Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - dialogContext.l10n.community_hashtagPrivacyHint, + sheetContext.l10n.community_hashtagPrivacyHint, style: TextStyle( fontSize: 12, color: Theme.of( - dialogContext, + sheetContext, ).colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), @@ -1092,7 +1181,7 @@ class _ChannelsScreenState extends State showDismissibleSnackBar( context, content: Text( - dialogContext + sheetContext .l10n .channels_enterChannelName, ), @@ -1115,9 +1204,9 @@ class _ChannelsScreenState extends State // Community hashtag - HMAC derivation from community secret if (selectedCommunity == null) { showDismissibleSnackBar( - dialogContext, + sheetContext, content: Text( - dialogContext + sheetContext .l10n .community_selectCommunity, ), @@ -1136,8 +1225,8 @@ class _ChannelsScreenState extends State _loadCommunities(); } - if (dialogContext.mounted) { - Navigator.pop(dialogContext); + if (sheetContext.mounted) { + Navigator.pop(sheetContext); } connector.setChannel( nextIndex, @@ -1155,7 +1244,7 @@ class _ChannelsScreenState extends State ); } }, - child: Text(dialogContext.l10n.common_add), + child: Text(sheetContext.l10n.common_add), ), ), ], @@ -1175,7 +1264,7 @@ class _ChannelsScreenState extends State Expanded( child: FilledButton.icon( onPressed: () async { - Navigator.pop(dialogContext); + Navigator.pop(sheetContext); if (context.mounted) { final result = await Navigator.push( context, @@ -1191,7 +1280,7 @@ class _ChannelsScreenState extends State } }, icon: const Icon(Icons.qr_code_scanner), - label: Text(dialogContext.l10n.community_scanQr), + label: Text(sheetContext.l10n.community_scanQr), ), ), ], @@ -1209,8 +1298,8 @@ class _ChannelsScreenState extends State child: TextField( controller: nameController, decoration: InputDecoration( - labelText: dialogContext.l10n.community_name, - hintText: dialogContext.l10n.community_enterName, + labelText: sheetContext.l10n.community_name, + hintText: sheetContext.l10n.community_enterName, border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.groups), ), @@ -1220,15 +1309,15 @@ class _ChannelsScreenState extends State CheckboxListTile( value: addPublicChannel, onChanged: (value) { - setDialogState(() { + setSheetState(() { addPublicChannel = value ?? true; }); }, title: Text( - dialogContext.l10n.community_addPublicChannel, + sheetContext.l10n.community_addPublicChannel, ), subtitle: Text( - dialogContext.l10n.community_addPublicChannelHint, + sheetContext.l10n.community_addPublicChannelHint, ), controlAffinity: ListTileControlAffinity.leading, contentPadding: const EdgeInsets.symmetric( @@ -1249,7 +1338,7 @@ class _ChannelsScreenState extends State showDismissibleSnackBar( context, content: Text( - dialogContext.l10n.community_enterName, + sheetContext.l10n.community_enterName, ), ); return; @@ -1277,8 +1366,8 @@ class _ChannelsScreenState extends State ); } - if (dialogContext.mounted) { - Navigator.pop(dialogContext); + if (sheetContext.mounted) { + Navigator.pop(sheetContext); } // Refresh communities list @@ -1307,12 +1396,13 @@ class _ChannelsScreenState extends State ); } }, - child: Text(dialogContext.l10n.common_create), + child: Text(sheetContext.l10n.common_create), ), ), ], ), ), + const SizedBox(height: 8), ], ); @@ -1321,84 +1411,80 @@ class _ChannelsScreenState extends State } } - return AlertDialog( - title: Text(dialogContext.l10n.channels_addChannel), - contentPadding: const EdgeInsets.symmetric(vertical: 16), - content: SizedBox( - width: double.maxFinite, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - buildOptionTile( - optionIndex: 0, - icon: Icons.add, - title: dialogContext.l10n.channels_createPrivateChannel, - subtitle: - dialogContext.l10n.channels_createPrivateChannelDesc, - ), - if (selectedOption == 0) - buildExpandedContent(_channelMessageStore)!, - const Divider(height: 1), - buildOptionTile( - optionIndex: 1, - icon: Icons.lock, - title: dialogContext.l10n.channels_joinPrivateChannel, - subtitle: - dialogContext.l10n.channels_joinPrivateChannelDesc, - ), - if (selectedOption == 1) - buildExpandedContent(_channelMessageStore)!, - if (!hasPublicChannel) ...[ - const Divider(height: 1), - buildOptionTile( - optionIndex: 2, - icon: Icons.public, - title: dialogContext.l10n.channels_joinPublicChannel, + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + builder: (_, scrollController) => Column( + children: [ + BottomSheetHeader( + title: sheetContext.l10n.channels_addChannel, + ), + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.only(bottom: 24), + children: [ + buildOptionCard( + optionIndex: 0, + icon: Icons.add, + title: sheetContext.l10n.channels_createPrivateChannel, subtitle: - dialogContext.l10n.channels_joinPublicChannelDesc, + sheetContext.l10n.channels_createPrivateChannelDesc, ), - if (selectedOption == 2) + if (selectedOption == 0) + buildExpandedContent(_channelMessageStore)!, + buildOptionCard( + optionIndex: 1, + icon: Icons.lock, + title: sheetContext.l10n.channels_joinPrivateChannel, + subtitle: + sheetContext.l10n.channels_joinPrivateChannelDesc, + ), + if (selectedOption == 1) + buildExpandedContent(_channelMessageStore)!, + if (!hasPublicChannel) ...[ + buildOptionCard( + optionIndex: 2, + icon: Icons.public, + title: sheetContext.l10n.channels_joinPublicChannel, + subtitle: + sheetContext.l10n.channels_joinPublicChannelDesc, + ), + if (selectedOption == 2) + buildExpandedContent(_channelMessageStore)!, + ], + buildOptionCard( + optionIndex: 3, + icon: Icons.tag, + title: sheetContext.l10n.channels_joinHashtagChannel, + subtitle: + sheetContext.l10n.channels_joinHashtagChannelDesc, + ), + if (selectedOption == 3) + buildExpandedContent(_channelMessageStore)!, + buildOptionCard( + optionIndex: 4, + icon: Icons.qr_code_scanner, + title: sheetContext.l10n.community_scanQr, + subtitle: sheetContext.l10n.community_join, + ), + if (selectedOption == 4) + buildExpandedContent(_channelMessageStore)!, + buildOptionCard( + optionIndex: 5, + icon: Icons.groups, + title: sheetContext.l10n.community_create, + subtitle: sheetContext.l10n.community_createDesc, + ), + if (selectedOption == 5) buildExpandedContent(_channelMessageStore)!, ], - const Divider(height: 1), - buildOptionTile( - optionIndex: 3, - icon: Icons.tag, - title: dialogContext.l10n.channels_joinHashtagChannel, - subtitle: - dialogContext.l10n.channels_joinHashtagChannelDesc, - ), - if (selectedOption == 3) - buildExpandedContent(_channelMessageStore)!, - const Divider(height: 1), - buildOptionTile( - optionIndex: 4, - icon: Icons.qr_code_scanner, - title: dialogContext.l10n.community_scanQr, - subtitle: dialogContext.l10n.community_join, - ), - if (selectedOption == 4) - buildExpandedContent(_channelMessageStore)!, - const Divider(height: 1), - buildOptionTile( - optionIndex: 5, - icon: Icons.groups, - title: dialogContext.l10n.community_create, - subtitle: dialogContext.l10n.community_createDesc, - ), - if (selectedOption == 5) - buildExpandedContent(_channelMessageStore)!, - ], + ), ), - ), + ], ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(dialogContext.l10n.common_close), - ), - ], ); }, ), @@ -1422,154 +1508,175 @@ class _ChannelsScreenState extends State channel.index, ); - showDialog( - context: context, - builder: (dialogContext) => StatefulBuilder( - builder: (dialogContext, setState) => AlertDialog( - title: Text( - dialogContext.l10n.channels_editChannelTitle(channel.index), - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameController, - decoration: InputDecoration( - labelText: dialogContext.l10n.channels_channelName, - border: const OutlineInputBorder(), - ), - maxLength: 31, - ), - const SizedBox(height: 16), - TextField( - controller: pskController, - decoration: InputDecoration( - labelText: dialogContext.l10n.channels_pskHex, - border: const OutlineInputBorder(), - suffixIcon: IconButton( - icon: const Icon(Icons.casino), - tooltip: dialogContext.l10n.channels_generateRandomPsk, - onPressed: () { - final random = Random.secure(); - final bytes = Uint8List(16); - for (int i = 0; i < 16; i++) { - bytes[i] = random.nextInt(256); - } - pskController.text = Channel.formatPskHex(bytes); - }, - ), - ), - ), - const SizedBox(height: 16), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(dialogContext.l10n.channels_smazCompression), - value: smazEnabled, - onChanged: (value) => setState(() { - smazEnabled = value; - if (smazEnabled) { - cyr2latEnabled = false; - } - }), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(dialogContext.l10n.channels_cyr2latCompression), - subtitle: Text( - dialogContext.l10n.channels_cyr2latCompressionDscr, - ), - value: cyr2latEnabled, - onChanged: (value) => setState(() { - cyr2latEnabled = value; - if (cyr2latEnabled) { - smazEnabled = false; - } - }), - ), - if (cyr2latEnabled) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 0, 8), - child: DropdownButtonFormField( - initialValue: selectedCyr2LatProfileId, + showMeshSheet( + context, + builder: (sheetContext) => StatefulBuilder( + builder: (sheetContext, setSheetState) => DraggableScrollableSheet( + expand: false, + initialChildSize: 0.65, + minChildSize: 0.4, + maxChildSize: 0.95, + builder: (_, scrollController) => Column( + children: [ + BottomSheetHeader( + title: sheetContext.l10n.channels_editChannelTitle(channel.index), + ), + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + const SizedBox(height: 8), + TextField( + controller: nameController, decoration: InputDecoration( - labelText: dialogContext - .l10n - .channels_cyr2latSettingsSubheading, + labelText: sheetContext.l10n.channels_channelName, border: const OutlineInputBorder(), ), - items: appSettingsService.settings.cyr2latProfiles.map(( - profile, - ) { - return DropdownMenuItem( - value: profile.id, - child: Text(profile.name), - ); - }).toList(), - onChanged: (value) => setState(() { - selectedCyr2LatProfileId = value; + maxLength: 31, + ), + const SizedBox(height: 16), + TextField( + controller: pskController, + decoration: InputDecoration( + labelText: sheetContext.l10n.channels_pskHex, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: const Icon(Icons.casino), + tooltip: sheetContext.l10n.channels_generateRandomPsk, + onPressed: () { + final random = Random.secure(); + final bytes = Uint8List(16); + for (int i = 0; i < 16; i++) { + bytes[i] = random.nextInt(256); + } + pskController.text = Channel.formatPskHex(bytes); + }, + ), + ), + ), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(sheetContext.l10n.channels_smazCompression), + value: smazEnabled, + onChanged: (value) => setSheetState(() { + smazEnabled = value; + if (smazEnabled) { + cyr2latEnabled = false; + } }), ), - ), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(dialogContext.l10n.common_cancel), - ), - FilledButton( - onPressed: () async { - final name = nameController.text.trim(); - final pskHex = pskController.text.trim(); - - Uint8List psk; - try { - psk = Channel.parsePskHex(pskHex); - } on FormatException { - showDismissibleSnackBar( - dialogContext, - content: Text(dialogContext.l10n.channels_pskMustBe32Hex), - ); - return; - } - - Navigator.pop(dialogContext); - try { - await connector.setChannel(channel.index, name, psk); - await connector.setChannelSmazEnabled( - channel.index, - smazEnabled, - ); - await connector.setChannelCyr2LatEnabled( - channel.index, - cyr2latEnabled, - ); - await connector.setChannelCyr2LatProfileId( - channel.index, - selectedCyr2LatProfileId, - ); - if (!context.mounted) return; - showDismissibleSnackBar( - context, - content: Text(context.l10n.channels_channelUpdated(name)), - ); - } catch (e, st) { - debugPrint(st.toString()); - if (!context.mounted) return; - showDismissibleSnackBar( - context, - content: Text( - context.l10n.channels_channelUpdateFailed('$e'), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(sheetContext.l10n.channels_cyr2latCompression), + subtitle: Text( + sheetContext.l10n.channels_cyr2latCompressionDscr, + ), + value: cyr2latEnabled, + onChanged: (value) => setSheetState(() { + cyr2latEnabled = value; + if (cyr2latEnabled) { + smazEnabled = false; + } + }), ), - ); - } - }, - child: Text(dialogContext.l10n.common_save), - ), - ], + if (cyr2latEnabled) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 8), + child: DropdownButtonFormField( + initialValue: selectedCyr2LatProfileId, + decoration: InputDecoration( + labelText: sheetContext + .l10n + .channels_cyr2latSettingsSubheading, + border: const OutlineInputBorder(), + ), + items: appSettingsService.settings.cyr2latProfiles.map(( + profile, + ) { + return DropdownMenuItem( + value: profile.id, + child: Text(profile.name), + ); + }).toList(), + onChanged: (value) => setSheetState(() { + selectedCyr2LatProfileId = value; + }), + ), + ), + ], + const SizedBox(height: 24), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(sheetContext), + child: Text(sheetContext.l10n.common_cancel), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () async { + final name = nameController.text.trim(); + final pskHex = pskController.text.trim(); + + Uint8List psk; + try { + psk = Channel.parsePskHex(pskHex); + } on FormatException { + showDismissibleSnackBar( + sheetContext, + content: Text(sheetContext.l10n.channels_pskMustBe32Hex), + ); + return; + } + + Navigator.pop(sheetContext); + try { + await connector.setChannel(channel.index, name, psk); + await connector.setChannelSmazEnabled( + channel.index, + smazEnabled, + ); + await connector.setChannelCyr2LatEnabled( + channel.index, + cyr2latEnabled, + ); + await connector.setChannelCyr2LatProfileId( + channel.index, + selectedCyr2LatProfileId, + ); + if (!context.mounted) return; + showDismissibleSnackBar( + context, + content: Text(context.l10n.channels_channelUpdated(name)), + ); + } catch (e, st) { + debugPrint(st.toString()); + if (!context.mounted) return; + showDismissibleSnackBar( + context, + content: Text( + context.l10n.channels_channelUpdateFailed('$e'), + ), + ); + } + }, + child: Text(sheetContext.l10n.common_save), + ), + ), + ], + ), + ), + ], + ), ), ), ); @@ -1625,7 +1732,7 @@ class _ChannelsScreenState extends State }, child: Text( dialogContext.l10n.common_delete, - style: TextStyle(color: Theme.of(context).colorScheme.error), + style: TextStyle(color: Theme.of(dialogContext).colorScheme.error), ), ), ], @@ -1651,9 +1758,8 @@ class _ChannelsScreenState extends State } void _showManageCommunitiesDialog(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, + showMeshSheet( + context, builder: (sheetContext) => DraggableScrollableSheet( initialChildSize: 0.5, minChildSize: 0.3, @@ -1661,18 +1767,8 @@ class _ChannelsScreenState extends State expand: false, builder: (_, scrollController) => Column( children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.groups, size: 28), - const SizedBox(width: 12), - Text( - context.l10n.community_manageCommunities, - style: Theme.of(context).textTheme.titleLarge, - ), - ], - ), + BottomSheetHeader( + title: sheetContext.l10n.community_manageCommunities, ), const Divider(height: 1), Expanded( @@ -1684,27 +1780,27 @@ class _ChannelsScreenState extends State Icon( Icons.groups_outlined, size: 64, - color: Theme.of(context) + color: Theme.of(sheetContext) .colorScheme .onSurfaceVariant .withValues(alpha: 0.6), ), const SizedBox(height: 16), Text( - context.l10n.community_noCommunities, + sheetContext.l10n.community_noCommunities, style: TextStyle( fontSize: 16, color: Theme.of( - context, + sheetContext, ).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), Text( - context.l10n.community_scanOrCreate, + sheetContext.l10n.community_scanOrCreate, style: TextStyle( fontSize: 14, - color: Theme.of(context) + color: Theme.of(sheetContext) .colorScheme .onSurfaceVariant .withValues(alpha: 0.8), @@ -1721,12 +1817,10 @@ class _ChannelsScreenState extends State final community = _communities[index]; return ListTile( leading: CircleAvatar( - backgroundColor: Colors.purple.withValues( - alpha: 0.2, - ), + backgroundColor: MeshPalette.magentaBg, child: const Icon( Icons.groups, - color: Colors.purple, + color: MeshPalette.magenta, ), ), title: Text(community.name), @@ -1744,10 +1838,12 @@ class _ChannelsScreenState extends State trailing: PopupMenuButton( onSelected: (value) { Navigator.pop(sheetContext); + // Use the screen's context: the sheet item's + // context is deactivated once the sheet pops. if (value == 'share') { - _showCommunityQrDialog(context, community); + _showCommunityQrDialog(this.context, community); } else if (value == 'leave') { - _confirmLeaveCommunity(context, community); + _confirmLeaveCommunity(this.context, community); } }, itemBuilder: (context) => [ @@ -1879,7 +1975,7 @@ class _ChannelsScreenState extends State }, child: Text( dialogContext.l10n.community_delete, - style: TextStyle(color: Theme.of(context).colorScheme.error), + style: TextStyle(color: Theme.of(dialogContext).colorScheme.error), ), ), ], diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 89310bbd..3b0a2441 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -30,7 +30,6 @@ import '../widgets/chat_zoom_wrapper.dart'; import '../widgets/byte_count_input.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; -import '../helpers/contact_ui.dart'; import '../widgets/emoji_picker.dart'; import '../widgets/gif_message.dart'; import '../widgets/jump_to_bottom_button.dart'; @@ -43,6 +42,8 @@ import '../widgets/translated_message_content.dart'; import '../l10n/l10n.dart'; import '../helpers/snack_bar_builder.dart'; import '../widgets/unread_divider.dart'; +import '../theme/mesh_theme.dart'; +import '../widgets/mesh_ui.dart'; import 'telemetry_screen.dart'; class ChatScreen extends StatefulWidget { @@ -448,119 +449,157 @@ class _ChatScreenState extends State { Widget _buildInputBar(MeshCoreConnector connector) { final maxBytes = maxContactMessageBytes(); - final colorScheme = Theme.of(context).colorScheme; + final scheme = Theme.of(context).colorScheme; final settings = context.watch().settings; return Container( - padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surface, - border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), + color: scheme.surface, + border: Border(top: BorderSide(color: scheme.outlineVariant, width: 1)), ), child: SafeArea( - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.gif_box), - onPressed: () => _showGifPicker(context), - tooltip: context.l10n.chat_sendGif, - ), - if (settings.translationEnabled) - MessageTranslationButton( - enabled: settings.composerTranslationEnabled, - languageCode: settings.translationTargetLanguageCode, - onPressed: _showTranslationOptions, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.gif_box), + onPressed: () => _showGifPicker(context), + tooltip: context.l10n.chat_sendGif, ), - Expanded( - child: ValueListenableBuilder( - valueListenable: _textController, - builder: (context, value, child) { - final gifId = GifHelper.parseGif(value.text); - if (gifId != null) { - return Focus( - autofocus: true, - onKeyEvent: (node, event) { - if (event is KeyDownEvent && - (event.logicalKey == LogicalKeyboardKey.enter || - event.logicalKey == - LogicalKeyboardKey.numpadEnter)) { - _sendMessage(connector); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', - backgroundColor: - colorScheme.surfaceContainerHighest, - fallbackTextColor: colorScheme.onSurface - .withValues(alpha: 0.6), - maxSize: 160, + if (settings.translationEnabled) + MessageTranslationButton( + enabled: settings.composerTranslationEnabled, + languageCode: settings.translationTargetLanguageCode, + onPressed: _showTranslationOptions, + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: _textController, + builder: (context, value, child) { + final gifId = GifHelper.parseGif(value.text); + if (gifId != null) { + return Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == + LogicalKeyboardKey.numpadEnter)) { + _sendMessage(connector); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: GifMessage( + url: + 'https://media.giphy.com/media/$gifId/giphy.gif', + backgroundColor: + scheme.surfaceContainerHighest, + fallbackTextColor: scheme.onSurface + .withValues(alpha: 0.6), + maxSize: 160, + ), ), ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + _textController.clear(); + _textFieldFocusNode.requestFocus(); + }, + ), + ], + ), + ); + } + return ByteCountedTextField( + maxBytes: maxBytes, + controller: _textController, + focusNode: _textFieldFocusNode, + hintText: context.l10n.chat_typeMessage, + onSubmitted: (_) => _sendMessage(connector), + encoder: + (connector.isContactSmazEnabled( + widget.contact.publicKeyHex, + ) || + connector.isContactCyr2LatEnabled( + widget.contact.publicKeyHex, + )) + ? (text) => connector.prepareContactOutboundText( + widget.contact, + text, + ) + : null, + decoration: InputDecoration( + hintText: context.l10n.chat_typeMessage, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + borderSide: BorderSide(color: scheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + borderSide: BorderSide( + color: scheme.primary, + width: 1.5, ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.close), - onPressed: () { - _textController.clear(); - _textFieldFocusNode.requestFocus(); - }, - ), - ], + ), + filled: true, + fillColor: scheme.surfaceContainerLow, + contentPadding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), ), ); - } - return ByteCountedTextField( - maxBytes: maxBytes, - controller: _textController, - focusNode: _textFieldFocusNode, - hintText: context.l10n.chat_typeMessage, - onSubmitted: (_) => _sendMessage(connector), - encoder: - (connector.isContactSmazEnabled( - widget.contact.publicKeyHex, - ) || - connector.isContactCyr2LatEnabled( - widget.contact.publicKeyHex, - )) - ? (text) => connector.prepareContactOutboundText( - widget.contact, - text, - ) - : null, - decoration: InputDecoration( - hintText: context.l10n.chat_typeMessage, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), + }, + ), + ), + const SizedBox(width: 6), + ValueListenableBuilder( + valueListenable: _textController, + builder: (context, value, _) { + final hasText = value.text.trim().isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeInOut, + child: IconButton.filled( + icon: const Icon(Icons.send, size: 20), + tooltip: context.l10n.chat_sendMessageTo( + _resolveContact(connector).name, ), - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.surfaceContainerLow, - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 14, + style: IconButton.styleFrom( + backgroundColor: hasText + ? scheme.primary + : scheme.surfaceContainerHighest, + foregroundColor: hasText + ? scheme.onPrimary + : scheme.onSurfaceVariant, + minimumSize: const Size(40, 40), + shape: const CircleBorder(), ), + onPressed: hasText + ? () { + HapticFeedback.lightImpact(); + _sendMessage(connector); + } + : null, ), ); }, ), - ), - const SizedBox(width: 8), - IconButton.filled( - icon: const Icon(Icons.send), - tooltip: context.l10n.chat_sendMessageTo( - _resolveContact(connector).name, - ), - onPressed: () => _sendMessage(connector), - ), - ], + ], + ), ), ), ); @@ -1043,12 +1082,17 @@ class _ChatScreenState extends State { ) && (message.translatedText?.trim().isEmpty ?? true); - showModalBottomSheet( - context: context, + showMeshSheet( + context, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ + BottomSheetHeader( + title: message.text.length > 40 + ? '${message.text.substring(0, 40)}…' + : message.text, + ), // Can't react to your own messages if (!message.isOutgoing) ListTile( @@ -1118,7 +1162,7 @@ class _ChatScreenState extends State { _openChat(context, contact); }, ), - const Divider(), + const Divider(height: 1), ListTile( leading: Icon( Icons.delete_outline, @@ -1133,6 +1177,7 @@ class _ChatScreenState extends State { await _deleteMessage(message); }, ), + const SizedBox(height: 8), ], ), ), @@ -1221,20 +1266,45 @@ class _MessageBubble extends StatelessWidget { final settingsService = context.watch(); final enableTracing = settingsService.settings.enableMessageTracing; final isOutgoing = message.isOutgoing; - final colorScheme = Theme.of(context).colorScheme; + final scheme = Theme.of(context).colorScheme; final gifId = GifHelper.parseGif(message.text); final poi = parseMarkerText(message.text); final isFailed = message.status == MessageStatus.failed; + + // Bubble colors — outgoing uses MeshPalette.me / meBorder / meInk. final bubbleColor = isFailed - ? colorScheme.errorContainer - : (isOutgoing - ? colorScheme.primary - : colorScheme.surfaceContainerHighest); + ? scheme.errorContainer + : isOutgoing + ? MeshPalette.me + : scheme.surfaceContainerLow; + final bubbleBorder = isFailed + ? scheme.error + : isOutgoing + ? MeshPalette.meBorder + : scheme.outlineVariant; final textColor = isFailed - ? colorScheme.onErrorContainer - : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); - final metaColor = textColor.withValues(alpha: 0.7); + ? scheme.onErrorContainer + : isOutgoing + ? MeshPalette.meInk + : scheme.onSurface; + final metaColor = textColor.withValues(alpha: 0.65); const bodyFontSize = 14.0; + + // Asymmetric radius: outgoing — top-left large, others also large; outgoing bottom-right tight. + final borderRadius = isOutgoing + ? const BorderRadius.only( + topLeft: Radius.circular(MeshRadii.lg), + topRight: Radius.circular(MeshRadii.lg), + bottomLeft: Radius.circular(MeshRadii.lg), + bottomRight: Radius.circular(MeshRadii.xs), + ) + : const BorderRadius.only( + topLeft: Radius.circular(MeshRadii.xs), + topRight: Radius.circular(MeshRadii.lg), + bottomLeft: Radius.circular(MeshRadii.lg), + bottomRight: Radius.circular(MeshRadii.lg), + ); + // Do not strip room-server author bytes here: the parser stores them in // fourByteRoomContactKey, so message.text is safe to render as-is. final messageText = message.text; @@ -1246,8 +1316,9 @@ class _MessageBubble extends StatelessWidget { final originalDisplayText = isOutgoing ? message.originalText : (translatedDisplayText != messageText ? messageText : null); + return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 3), child: Column( crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end @@ -1263,11 +1334,11 @@ class _MessageBubble extends StatelessWidget { mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isOutgoing) ...[ - _buildAvatar(senderName, colorScheme), - const SizedBox(width: 8), + _buildAvatar(senderName), + const SizedBox(width: 6), ], Flexible( child: Container( @@ -1278,14 +1349,12 @@ class _MessageBubble extends StatelessWidget { vertical: 8, ), constraints: BoxConstraints( - maxWidth: constraints.maxWidth * 0.65, + maxWidth: constraints.maxWidth * 0.72, ), decoration: BoxDecoration( color: bubbleColor, - borderRadius: BorderRadius.circular(16), - border: isFailed - ? Border.all(color: colorScheme.error) - : null, + borderRadius: borderRadius, + border: Border.all(color: bubbleBorder, width: 1), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1301,14 +1370,14 @@ class _MessageBubble extends StatelessWidget { : EdgeInsets.zero, child: Text( senderName, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: colorScheme.primary, + style: MeshTheme.mono( + fontSize: 11, + fontWeight: FontWeight.w700, + color: _colorForName(senderName), ), ), ), - if (gifId == null) const SizedBox(height: 4), + if (gifId == null) const SizedBox(height: 2), ], if (poi != null) _buildPoiMessage( @@ -1349,7 +1418,7 @@ class _MessageBubble extends StatelessWidget { fontSize: bodyFontSize * textScale, ), originalStyle: TextStyle( - color: textColor.withValues(alpha: 0.78), + color: textColor.withValues(alpha: 0.72), fontSize: bodyFontSize * textScale, ), ), @@ -1359,7 +1428,7 @@ class _MessageBubble extends StatelessWidget { if (enableTracing && isOutgoing && message.retryCount > 0) ...[ - const SizedBox(height: 4), + const SizedBox(height: 3), Padding( padding: gifId != null ? const EdgeInsets.symmetric(horizontal: 8) @@ -1372,15 +1441,15 @@ class _MessageBubble extends StatelessWidget { .settings .maxMessageRetries, ), - style: TextStyle( - fontSize: 10 * textScale, + style: MeshTheme.mono( + fontSize: 9.5 * textScale, color: metaColor, - fontWeight: FontWeight.w500, ), ), ), ], - const SizedBox(height: 4), + const SizedBox(height: 3), + // Meta row: timestamp + status icon + optional tracing Padding( padding: gifId != null ? const EdgeInsets.only( @@ -1395,13 +1464,13 @@ class _MessageBubble extends StatelessWidget { children: [ Text( _formatTime(message.timestamp), - style: TextStyle( + style: MeshTheme.mono( fontSize: 10 * textScale, color: metaColor, ), ), if (isOutgoing) ...[ - const SizedBox(width: 4), + const SizedBox(width: 2), MessageStatusIcon( size: 12 * textScale, onColor: metaColor, @@ -1418,25 +1487,21 @@ class _MessageBubble extends StatelessWidget { message.tripTimeMs != null && message.status == MessageStatus.delivered) ...[ - const SizedBox(width: 4), + const SizedBox(width: 2), Icon( Icons.speed, size: 10 * textScale, color: isOutgoing ? metaColor - : Theme.of( - context, - ).colorScheme.tertiary, + : scheme.tertiary, ), Text( '${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s', - style: TextStyle( + style: MeshTheme.mono( fontSize: 9 * textScale, color: isOutgoing ? metaColor - : Theme.of( - context, - ).colorScheme.tertiary, + : scheme.tertiary, ), ), ], @@ -1454,8 +1519,8 @@ class _MessageBubble extends StatelessWidget { if (message.reactions.isNotEmpty) ...[ const SizedBox(height: 4), Padding( - padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), - child: _buildReactionsDisplay(context, message, colorScheme), + padding: EdgeInsets.only(left: isOutgoing ? 0 : 42), + child: _buildReactionsDisplay(context, message, scheme), ), ], ], @@ -1532,7 +1597,7 @@ class _MessageBubble extends StatelessWidget { Widget _buildReactionsDisplay( BuildContext context, Message message, - ColorScheme colorScheme, + ColorScheme scheme, ) { return Wrap( spacing: 6, @@ -1555,28 +1620,33 @@ class _MessageBubble extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: isFailed - ? colorScheme.errorContainer - : colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(12), + ? scheme.errorContainer + : scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(MeshRadii.pill), border: Border.all( - color: isFailed - ? colorScheme.error - : colorScheme.outline.withValues(alpha: 0.3), + color: isFailed ? scheme.error : scheme.outlineVariant, width: 1, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(emoji, style: const TextStyle(fontSize: 16)), + Text( + emoji, + style: MeshTheme.emoji(fontSize: 16), + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ), + ), if (count > 1) ...[ const SizedBox(width: 4), Text( '$count', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: colorScheme.onSecondaryContainer, + style: MeshTheme.mono( + fontSize: 11, + fontWeight: FontWeight.w700, + color: scheme.onSurface, ), ), ], @@ -1587,13 +1657,13 @@ class _MessageBubble extends StatelessWidget { height: 8, child: CircularProgressIndicator( strokeWidth: 1.5, - color: colorScheme.onSecondaryContainer, + color: scheme.primary, ), ), ], if (isFailed) ...[ const SizedBox(width: 2), - Icon(Icons.replay, size: 10, color: colorScheme.error), + Icon(Icons.replay, size: 10, color: scheme.error), ], ], ), @@ -1604,22 +1674,8 @@ class _MessageBubble extends StatelessWidget { ); } - Widget _buildAvatar(String senderName, ColorScheme colorScheme) { - final initial = firstCharacterOrEmoji(senderName); - final color = colorForName(senderName); - - return CircleAvatar( - radius: 18, - backgroundColor: color.withValues(alpha: 0.2), - child: Text( - initial, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: color, - ), - ), - ); + Widget _buildAvatar(String senderName) { + return AvatarCircle(name: senderName, size: 32); } String _formatTime(DateTime time) { @@ -1628,3 +1684,20 @@ class _MessageBubble extends StatelessWidget { return '$hour:$minute'; } } + +/// Deterministic name-to-hue mapping consistent with [AvatarCircle]. +Color _colorForName(String name) { + const hues = [ + MeshPalette.blue, + MeshPalette.magenta, + MeshPalette.signal, + MeshPalette.warn, + Color(0xFF8FA8F0), + Color(0xFF6FD9CE), + ]; + var h = 0; + for (final c in name.codeUnits) { + h = (h * 31 + c) & 0x7fffffff; + } + return hues[h % hues.length]; +} diff --git a/lib/screens/chrome_required_screen.dart b/lib/screens/chrome_required_screen.dart index cdf3c938..4507b10f 100644 --- a/lib/screens/chrome_required_screen.dart +++ b/lib/screens/chrome_required_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import '../l10n/l10n.dart'; +import '../theme/mesh_theme.dart'; +import '../widgets/mesh_ui.dart'; class ChromeRequiredScreen extends StatelessWidget { const ChromeRequiredScreen({super.key}); @@ -7,78 +9,95 @@ class ChromeRequiredScreen extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; + final scheme = Theme.of(context).colorScheme; + return Scaffold( - body: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 32), - color: colorScheme.surface, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer.withValues(alpha: 0.4), - shape: BoxShape.circle, - ), - child: Icon( - Icons.browser_not_supported_rounded, - size: 80, - color: colorScheme.tertiary, - ), - ), - const SizedBox(height: 32), - Text( - l10n.scanner_chromeRequired, - textAlign: TextAlign.center, - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - const SizedBox(height: 16), - Text( - l10n.scanner_chromeRequiredMessage, - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.5, - ), - ), - const SizedBox(height: 48), - // We can't really "fix" it for them other than telling them to use Chrome - // but we can provide a nice visual. - Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(30), - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.4), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.info_outline, - size: 20, - color: colorScheme.secondary, - ), - const SizedBox(width: 12), - Text( - l10n.chrome_bluetoothRequiresChromium, - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSecondaryContainer, - fontWeight: FontWeight.w500, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Icon in tinted circle + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scheme.tertiary.withValues(alpha: 0.10), + border: Border.all( + color: scheme.tertiary.withValues(alpha: 0.25), + width: 1.5, ), ), - ], - ), + child: Icon( + Icons.browser_not_supported_rounded, + size: 42, + color: scheme.tertiary, + ), + ), + const SizedBox(height: 28), + + // Title + Text( + l10n.scanner_chromeRequired, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: scheme.onSurface, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 12), + + // Body text + Text( + l10n.scanner_chromeRequiredMessage, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: scheme.onSurfaceVariant, + height: 1.55, + ), + ), + const SizedBox(height: 32), + + // Info chip + MeshCard( + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + color: scheme.secondaryContainer.withValues(alpha: 0.35), + borderColor: scheme.outline.withValues(alpha: 0.3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.info_outline, + size: 18, + color: scheme.secondary, + ), + const SizedBox(width: 10), + Flexible( + child: Text( + l10n.chrome_bluetoothRequiresChromium, + style: MeshTheme.mono( + fontSize: 12, + color: scheme.onSecondaryContainer, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ], ), - ], + ), ), ), ); diff --git a/lib/screens/community_qr_scanner_screen.dart b/lib/screens/community_qr_scanner_screen.dart index 6b71715f..c7941900 100644 --- a/lib/screens/community_qr_scanner_screen.dart +++ b/lib/screens/community_qr_scanner_screen.dart @@ -1,14 +1,18 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; import '../connector/meshcore_connector.dart'; +import '../helpers/snack_bar_builder.dart'; import '../l10n/l10n.dart'; import '../models/community.dart'; import '../storage/community_store.dart'; +import '../theme/mesh_theme.dart'; import '../widgets/adaptive_app_bar_title.dart'; +import '../widgets/mesh_ui.dart'; import '../widgets/qr_scanner_widget.dart'; -import '../helpers/snack_bar_builder.dart'; /// Screen for scanning community QR codes to join communities. /// @@ -35,16 +39,90 @@ class _CommunityQrScannerScreenState extends State { centerTitle: true, ), body: _isProcessing - ? const Center(child: CircularProgressIndicator()) + ? Container( + color: Theme.of(context).colorScheme.surface, + child: const Center(child: CircularProgressIndicator()), + ) : QrScannerWidget( onScanned: (data) => _handleScannedData(context, data), validator: Community.isValidQrData, onValidationFailed: (_) => _showInvalidQrError(context), instructions: context.l10n.community_scanInstructions, + overlay: _buildThemedOverlay(context), ), ); } + Widget _buildThemedOverlay(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + // Dark semi-transparent background with cutout + ColorFiltered( + colorFilter: ColorFilter.mode( + Colors.black.withValues(alpha: 0.5), + BlendMode.srcOut, + ), + child: Stack( + fit: StackFit.expand, + children: [ + Container( + decoration: const BoxDecoration( + color: Colors.black, + backgroundBlendMode: BlendMode.dstOut, + ), + ), + Center( + child: Container( + height: 250, + width: 250, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ], + ), + ), + // Corner brackets on top + const ScannerCornerOverlay( + scanWindowSize: 250, + borderColor: MeshPalette.blue, + borderWidth: 2, + cornerLength: 24, + ), + // Instructions pill below the scan window + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 250 + 24), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(MeshRadii.pill), + ), + child: Text( + context.l10n.community_scanInstructions, + style: const TextStyle( + color: MeshPalette.ink2, + fontSize: 13, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ], + ); + } + Future _handleScannedData(BuildContext context, String data) async { if (_isProcessing) return; @@ -80,7 +158,7 @@ class _CommunityQrScannerScreenState extends State { showDismissibleSnackBar( context, content: Text(context.l10n.community_invalidQrCode), - backgroundColor: Colors.red, + backgroundColor: MeshPalette.alert, ); } } finally { @@ -96,29 +174,74 @@ class _CommunityQrScannerScreenState extends State { showDismissibleSnackBar( context, content: Text(context.l10n.community_invalidQrCode), - backgroundColor: Colors.orange, + backgroundColor: MeshPalette.warn, duration: const Duration(seconds: 2), ); } void _showAlreadyMemberDialog(BuildContext context, Community community) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(context.l10n.community_alreadyMember), - content: Text( - context.l10n.community_alreadyMemberMessage(community.name), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(dialogContext); - Navigator.pop(context); - }, - child: Text(context.l10n.common_ok), + showMeshSheet( + context, + builder: (sheetContext) { + final sheetScheme = Theme.of(sheetContext).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BottomSheetHeader(title: context.l10n.community_alreadyMember), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 4), + child: Text( + context.l10n.community_alreadyMemberMessage(community.name), + style: TextStyle(color: sheetScheme.onSurfaceVariant), + ), + ), + MeshCard( + child: Row( + children: [ + const Icon( + Icons.groups, + color: MeshPalette.magenta, + size: 32, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + community.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + Text( + 'ID: ${community.shortCommunityId}...', + style: MeshTheme.mono( + fontSize: 11.5, + color: sheetScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: FilledButton( + onPressed: () { + Navigator.pop(sheetContext); + Navigator.pop(context); + }, + child: Text(context.l10n.common_ok), + ), ), ], - ), + ); + }, ); } @@ -127,38 +250,51 @@ class _CommunityQrScannerScreenState extends State { Community community, ) async { bool addPublicChannel = true; + final completer = Completer(); - final result = await showDialog( - context: context, - builder: (dialogContext) => StatefulBuilder( - builder: (dialogContext, setDialogState) => AlertDialog( - title: Text(context.l10n.community_joinTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.community_joinConfirmation(community.name)), - const SizedBox(height: 16), - Row( + await showMeshSheet( + context, + builder: (sheetContext) => StatefulBuilder( + builder: (sheetContext, setSheetState) { + final joinScheme = Theme.of(sheetContext).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BottomSheetHeader(title: context.l10n.community_joinTitle), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 4), + child: Text( + context.l10n.community_joinConfirmation(community.name), + style: TextStyle(color: joinScheme.onSurfaceVariant), + ), + ), + MeshCard( + child: Row( children: [ - Icon( - Icons.groups, - color: Theme.of(dialogContext).colorScheme.primary, + AvatarCircle( + name: community.name, + icon: Icons.groups, + color: MeshPalette.magenta, + size: 44, ), - const SizedBox(width: 12), + const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( community.name, - style: const TextStyle(fontWeight: FontWeight.bold), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + ), ), Text( 'ID: ${community.shortCommunityId}...', - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], + style: MeshTheme.mono( + fontSize: 11.5, + color: joinScheme.onSurfaceVariant, ), ), ], @@ -166,38 +302,59 @@ class _CommunityQrScannerScreenState extends State { ), ], ), - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 8), - CheckboxListTile( - value: addPublicChannel, - onChanged: (value) { - setDialogState(() { - addPublicChannel = value ?? true; - }); - }, - title: Text(context.l10n.community_addPublicChannel), - subtitle: Text(context.l10n.community_addPublicChannelHint), - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(context.l10n.common_cancel), ), - FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), - child: Text(context.l10n.community_join), + CheckboxListTile( + value: addPublicChannel, + onChanged: (value) { + setSheetState(() { + addPublicChannel = value ?? true; + }); + }, + title: Text(context.l10n.community_addPublicChannel), + subtitle: Text(context.l10n.community_addPublicChannelHint), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + completer.complete(false); + Navigator.pop(sheetContext); + }, + child: Text(context.l10n.common_cancel), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () { + completer.complete(true); + Navigator.pop(sheetContext); + }, + child: Text(context.l10n.community_join), + ), + ), + ], + ), ), ], - ), + ); + }, ), ); - if (result == true && context.mounted) { + // If sheet was dismissed without a button press, treat as cancel + if (!completer.isCompleted) { + completer.complete(false); + } + + final result = await completer.future; + + if (result && context.mounted) { await _joinCommunity(context, community, addPublicChannel); } else if (context.mounted) { // User cancelled - go back @@ -231,7 +388,7 @@ class _CommunityQrScannerScreenState extends State { showDismissibleSnackBar( context, content: Text(context.l10n.community_joined(community.name)), - backgroundColor: Colors.green, + backgroundColor: MeshPalette.signal, ); // Return to previous screen diff --git a/lib/screens/companion_radio_stats_screen.dart b/lib/screens/companion_radio_stats_screen.dart index f666254c..a36a2a6c 100644 --- a/lib/screens/companion_radio_stats_screen.dart +++ b/lib/screens/companion_radio_stats_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/models/companion_radio_stats.dart'; import 'package:meshcore_open/l10n/l10n.dart'; +import 'package:meshcore_open/theme/mesh_theme.dart'; +import 'package:meshcore_open/widgets/mesh_ui.dart'; import 'package:provider/provider.dart'; class CompanionRadioStatsScreen extends StatefulWidget { @@ -49,6 +51,25 @@ class _CompanionRadioStatsScreenState extends State { super.dispose(); } + Widget _tile(String text, IconData icon, Color color) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 10), + Expanded( + child: Text( + text, + style: MeshTheme.mono(fontSize: 13, color: scheme.onSurface), + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -85,44 +106,108 @@ class _CompanionRadioStatsScreenState extends State { valueListenable: connector.radioStatsNotifier, builder: (context, stats, _) { return ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(vertical: 8), children: [ if (stats != null) ...[ - Text( - l10n.radioStats_noiseFloor(stats.noiseFloorDbm), - style: tt.titleMedium, + const SectionHeader( + 'Signal', + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), ), - const SizedBox(height: 4), - Text(l10n.radioStats_lastRssi(stats.lastRssiDbm)), - Text( - l10n.radioStats_lastSnr( - stats.lastSnrDb.toStringAsFixed(1), + MeshCard( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _tile( + l10n.radioStats_noiseFloor(stats.noiseFloorDbm), + Icons.noise_aware, + scheme.onSurfaceVariant, + ), + const Divider(height: 1), + _tile( + l10n.radioStats_lastRssi(stats.lastRssiDbm), + Icons.wifi_tethering, + scheme.onSurfaceVariant, + ), + const Divider(height: 1), + _tile( + l10n.radioStats_lastSnr( + stats.lastSnrDb.toStringAsFixed(1), + ), + Icons.signal_cellular_alt, + MeshTheme.snrColor( + stats.lastSnrDb, + blocked: false, + ), + ), + ], ), ), - Text(l10n.radioStats_txAir(stats.txAirSecs)), - Text(l10n.radioStats_rxAir(stats.rxAirSecs)), - const SizedBox(height: 16), - ] else - Text(l10n.radioStats_waiting), - const SizedBox(height: 16), - SizedBox( - height: 200, - child: CustomPaint( - painter: _NoiseChartPainter( - samples: List.from(_noiseHistory), - colorScheme: scheme, - textTheme: tt, + const SectionHeader( + 'Airtime', + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + ), + MeshCard( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _tile( + l10n.radioStats_txAir(stats.txAirSecs), + Icons.upload, + MeshPalette.blue, + ), + const Divider(height: 1), + _tile( + l10n.radioStats_rxAir(stats.rxAirSecs), + Icons.download, + MeshPalette.blue, + ), + ], + ), + ), + ] else ...[ + const SizedBox(height: 80), + Center( + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 8), + Center( + child: Text( + l10n.radioStats_waiting, + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ), + ], + SectionHeader( + l10n.radioStats_chartCaption, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + height: 200, + child: CustomPaint( + painter: _NoiseChartPainter( + samples: List.from(_noiseHistory), + colorScheme: scheme, + textTheme: tt, + ), + child: const SizedBox.expand(), ), - child: const SizedBox.expand(), ), ), const SizedBox(height: 8), - Text( - l10n.radioStats_chartCaption, - style: tt.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), ], ); }, diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 95cbb55d..69fb67e0 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -16,6 +16,7 @@ import '../models/contact.dart'; import '../l10n/contact_localization.dart'; import '../models/contact_group.dart'; import '../services/ui_view_state_service.dart'; +import '../theme/mesh_theme.dart'; import '../utils/contact_search.dart'; import '../storage/contact_group_store.dart'; import '../utils/dialog_utils.dart'; @@ -24,12 +25,12 @@ import '../utils/emoji_utils.dart'; import '../utils/route_transitions.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; +import '../widgets/mesh_ui.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; -import '../helpers/contact_ui.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; @@ -472,12 +473,13 @@ class _ContactsScreenState extends State } void _showAddContactSheet(BuildContext context) { - showModalBottomSheet( - context: context, + showMeshSheet( + context, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ + BottomSheetHeader(title: context.l10n.contacts_title), ListTile( leading: const Icon(Icons.paste), title: Text(context.l10n.contacts_addContactFromClipboard), @@ -499,6 +501,7 @@ class _ContactsScreenState extends State ); }, ), + const SizedBox(height: 8), ], ), ), @@ -909,7 +912,8 @@ class _ContactsScreenState extends State final unreadCount = connector.getUnreadCountForContact( contact, ); - return _ContactTile( + return _ContactTileEntrance( + index: index, contact: contact, lastSeen: _resolveLastSeen(contact), unreadCount: unreadCount, @@ -1343,17 +1347,22 @@ class _ContactsScreenState extends State final isRoom = contact.type == advTypeRoom; final isFavorite = contact.isFavorite; - showModalBottomSheet( - context: context, + showMeshSheet( + context, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ + BottomSheetHeader( + title: contact.name, + subtitle: contact.typeLabel(context.l10n), + ), if (isRepeater) ...[ ListTile( - leading: const Icon(Icons.radar, color: Colors.green), + leading: Icon(Icons.radar, color: MeshPalette.signal), title: Text(context.l10n.contacts_ping), onTap: () { + Navigator.pop(sheetContext); final hw = context .read() .pathHashByteWidth; @@ -1371,7 +1380,7 @@ class _ContactsScreenState extends State }, ), ListTile( - leading: const Icon(Icons.cell_tower, color: Colors.orange), + leading: Icon(Icons.cell_tower, color: MeshPalette.warn), title: Text(context.l10n.contacts_manageRepeater), onTap: () { Navigator.pop(sheetContext); @@ -1380,9 +1389,10 @@ class _ContactsScreenState extends State ), ] else if (isRoom) ...[ ListTile( - leading: const Icon(Icons.radar, color: Colors.green), + leading: Icon(Icons.radar, color: MeshPalette.signal), title: Text(context.l10n.contacts_pathTrace), onTap: () { + Navigator.pop(sheetContext); final hw = context .read() .pathHashByteWidth; @@ -1405,7 +1415,7 @@ class _ContactsScreenState extends State }, ), ListTile( - leading: const Icon(Icons.room, color: Colors.blue), + leading: Icon(Icons.meeting_room, color: MeshPalette.blue), title: Text(context.l10n.contacts_roomLogin), onTap: () { Navigator.pop(sheetContext); @@ -1413,10 +1423,7 @@ class _ContactsScreenState extends State }, ), ListTile( - leading: const Icon( - Icons.room_preferences, - color: Colors.orange, - ), + leading: Icon(Icons.room_preferences, color: MeshPalette.warn), title: Text(context.l10n.room_management), onTap: () { Navigator.pop(sheetContext); @@ -1430,9 +1437,10 @@ class _ContactsScreenState extends State ] else ...[ if (contact.pathLength > 0) ListTile( - leading: const Icon(Icons.radar, color: Colors.green), + leading: Icon(Icons.radar, color: MeshPalette.signal), title: Text(context.l10n.contacts_chatTraceRoute), onTap: () { + Navigator.pop(sheetContext); final hw = context .read() .pathHashByteWidth; @@ -1456,7 +1464,7 @@ class _ContactsScreenState extends State ListTile( leading: Icon( isFavorite ? Icons.star : Icons.star_border, - color: Colors.amber[700], + color: MeshPalette.warn, ), title: Text( isFavorite @@ -1501,6 +1509,7 @@ class _ContactsScreenState extends State _confirmDelete(context, connector, contact); }, ), + const SizedBox(height: 8), ], ), ), @@ -1555,82 +1564,173 @@ class _ContactTile extends StatelessWidget { required this.onLongPress, }); - @override - Widget build(BuildContext context) { - return GestureDetector( - onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null, - child: ListTile( - leading: CircleAvatar( - backgroundColor: contactTypeColor(contact.type), - child: _buildContactAvatar(contact), - ), - title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - contact.pathLabel(context.l10n), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - // Clamp text scaling in trailing section to prevent overflow while - // maintaining accessibility. Primary content (title/subtitle) scales normally. - trailing: MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear( - MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3), - ), - ), - child: SizedBox( - width: 96, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (unreadCount > 0) ...[ - UnreadBadge(count: unreadCount), - const SizedBox(height: 4), - ], - Text( - _formatLastSeen(context, lastSeen), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isFavorite) - Icon(Icons.star, size: 14, color: Colors.amber[700]), - if (isFavorite && contact.hasLocation) - const SizedBox(width: 2), - if (contact.hasLocation) - Icon( - Icons.location_on, - size: 14, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), - ), - ], - ), - ], - ), - ), - ), - onTap: onTap, - onLongPress: onLongPress, - ), - ); + /// Node-type avatar color per design language. + Color _avatarColor() { + switch (contact.type) { + case advTypeRepeater: + return MeshPalette.warn; + case advTypeRoom: + return MeshPalette.magenta; + case advTypeSensor: + return const Color(0xFF4ACCC4); // teal + default: + return MeshPalette.blue; // chat — AvatarCircle handles deterministic hue + } } - Widget _buildContactAvatar(Contact contact) { - final emoji = firstEmoji(contact.name); - if (emoji != null) { - return Text(emoji, style: const TextStyle(fontSize: 18)); + /// Node-type avatar icon. Returns null for chat nodes so AvatarCircle shows initials. + IconData? _avatarIcon() { + switch (contact.type) { + case advTypeRepeater: + return Icons.cell_tower; + case advTypeRoom: + return Icons.meeting_room; + case advTypeSensor: + return Icons.sensors; + default: + return null; // chat uses initials } - return Icon(contactTypeIcon(contact.type), color: Colors.white, size: 20); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final emoji = firstEmoji(contact.name); + final isChat = contact.type == advTypeChat; + final pathLen = contact.pathBytesForDisplay.length; + final isDirect = contact.pathLength >= 0; + final hasPath = pathLen > 0 || contact.pathLength == 0; + + return GestureDetector( + onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null, + child: MeshCard( + onTap: onTap, + onLongPress: onLongPress, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + // Avatar + if (emoji != null) + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scheme.surfaceContainerHigh, + border: Border.all(color: scheme.outlineVariant), + ), + alignment: Alignment.center, + child: Text(emoji, style: const TextStyle(fontSize: 20)), + ) + else + AvatarCircle( + name: contact.name, + size: 42, + color: isChat ? null : _avatarColor(), + icon: _avatarIcon(), + ), + const SizedBox(width: 12), + // Main content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Name row + route chip + Row( + children: [ + Expanded( + child: Text( + contact.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: unreadCount > 0 + ? FontWeight.w700 + : FontWeight.w500, + fontSize: 15, + color: scheme.onSurface, + ), + ), + ), + if (isFavorite) ...[ + const SizedBox(width: 4), + Icon(Icons.star, size: 13, color: MeshPalette.warn), + ], + if (contact.hasLocation) ...[ + const SizedBox(width: 4), + Icon( + Icons.location_on, + size: 13, + color: scheme.onSurfaceVariant.withValues(alpha: 0.55), + ), + ], + ], + ), + const SizedBox(height: 3), + // Path / subtitle row + Row( + children: [ + Expanded( + child: Text( + contact.pathLabel(context.l10n), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant, + ), + ), + ), + if (hasPath) ...[ + const SizedBox(width: 6), + RouteChip( + isDirect: isDirect, + hops: isDirect ? contact.pathLength : null, + ), + ], + ], + ), + ], + ), + ), + const SizedBox(width: 10), + // Trailing: time + unread badge + // Clamp text scale to prevent overflow in trailing section. + MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear( + MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (unreadCount > 0) ...[ + UnreadBadge(count: unreadCount), + const SizedBox(height: 4), + ], + Text( + _formatLastSeen(context, lastSeen), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: MeshTheme.mono( + fontSize: 11, + color: unreadCount > 0 + ? MeshPalette.blue + : scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ); } String _formatLastSeen(BuildContext context, DateTime lastSeen) { @@ -1655,3 +1755,39 @@ class _ContactTile extends StatelessWidget { : context.l10n.contacts_lastSeenDaysAgo(days); } } + +// Wrap each contact tile with staggered entrance. +class _ContactTileEntrance extends StatelessWidget { + final int index; + final Contact contact; + final DateTime lastSeen; + final int unreadCount; + final bool isFavorite; + final VoidCallback onTap; + final VoidCallback onLongPress; + + const _ContactTileEntrance({ + required this.index, + required this.contact, + required this.lastSeen, + required this.unreadCount, + required this.isFavorite, + required this.onTap, + required this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + return ListEntrance( + index: index, + child: _ContactTile( + contact: contact, + lastSeen: lastSeen, + unreadCount: unreadCount, + isFavorite: isFavorite, + onTap: onTap, + onLongPress: onLongPress, + ), + ); + } +} diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index 1bc392bf..8fb4cf95 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -7,12 +7,14 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; +import '../l10n/contact_localization.dart'; import '../models/contact.dart'; +import '../theme/mesh_theme.dart'; import '../utils/contact_search.dart'; import '../utils/platform_info.dart'; import '../widgets/app_bar.dart'; import '../widgets/list_filter_widget.dart'; -import '../helpers/contact_ui.dart'; +import '../widgets/mesh_ui.dart'; import '../helpers/snack_bar_builder.dart'; enum DiscoverySortOption { lastSeen, name, type } @@ -47,6 +49,34 @@ class _DiscoveryScreenState extends State { : contact.lastSeen; } + /// Node-type avatar color per design language. + Color _avatarColor(int type) { + switch (type) { + case advTypeRepeater: + return MeshPalette.warn; + case advTypeRoom: + return MeshPalette.magenta; + case advTypeSensor: + return const Color(0xFF4ACCC4); // teal + default: + return MeshPalette.blue; + } + } + + /// Node-type avatar icon; null = show initials for chat nodes. + IconData? _avatarIcon(int type) { + switch (type) { + case advTypeRepeater: + return Icons.cell_tower; + case advTypeRoom: + return Icons.meeting_room; + case advTypeSensor: + return Icons.sensors; + default: + return null; + } + } + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -93,121 +123,167 @@ class _DiscoveryScreenState extends State { children: [ _buildFilters(filteredAndSorted, connector), Expanded( - child: discoveredContacts.isEmpty - ? Center(child: Text(l10n.contacts_noContacts)) - : filteredAndSorted.isEmpty - ? Center(child: Text(l10n.discoveredContacts_noMatching)) - : ListView.builder( - itemCount: filteredAndSorted.length, - itemBuilder: (context, index) { - final contact = filteredAndSorted[index]; - final tile = ListTile( - leading: CircleAvatar( - backgroundColor: contactTypeColor(contact.type), - child: Icon( - contactTypeIcon(contact.type), - color: Colors.white, - size: 20, - ), - ), - title: Text( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + child: discoveredContacts.isEmpty + ? Center( + key: const ValueKey('empty_all'), + child: Text(l10n.contacts_noContacts), + ) + : filteredAndSorted.isEmpty + ? Center( + key: const ValueKey('empty_filtered'), + child: Text(l10n.discoveredContacts_noMatching), + ) + : ListView.builder( + key: const ValueKey('list'), + padding: const EdgeInsets.only(bottom: 24), + itemCount: filteredAndSorted.length, + itemBuilder: (context, index) { + final contact = filteredAndSorted[index]; + final tile = _buildDiscoveryTile( + context, + contact, + connector, + index, + ); + if (PlatformInfo.isDesktop) { + return GestureDetector( + onSecondaryTapUp: (_) => + _showContactContextMenu(contact, connector), + child: tile, + ); + } + return tile; + }, + ), + ), + ), + ], + ), + ); + } + + Widget _buildDiscoveryTile( + BuildContext context, + Contact contact, + MeshCoreConnector connector, + int index, + ) { + final scheme = Theme.of(context).colorScheme; + final isChat = contact.type == advTypeChat; + + return ListEntrance( + index: index, + child: MeshCard( + onTap: () { + connector.importDiscoveredContact(contact); + showDismissibleSnackBar( + context, + content: Text( + context.l10n.discoveredContacts_contactAdded, + ), + action: SnackBarAction( + label: context.l10n.common_undo, + onPressed: () => connector.removeContact(contact), + ), + ); + }, + onLongPress: () => _showContactContextMenu(contact, connector), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + AvatarCircle( + name: contact.name, + size: 42, + color: isChat ? null : _avatarColor(contact.type), + icon: _avatarIcon(contact.type), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Name + type chip + Row( + children: [ + Expanded( + child: Text( contact.name, maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 15, + ), ), - subtitle: Text( + ), + const SizedBox(width: 6), + StatusChip( + label: contact.typeLabel(context.l10n).toUpperCase(), + color: _avatarColor(contact.type), + icon: _avatarIcon(contact.type), + ), + ], + ), + const SizedBox(height: 3), + // Short pub key + Row( + children: [ + Expanded( + child: Text( contact.shortPubKeyHex, maxLines: 1, overflow: TextOverflow.ellipsis, - ), - // Clamp text scaling in trailing section to prevent overflow while - // maintaining accessibility. Primary content (title/subtitle) scales normally. - trailing: MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear( - MediaQuery.textScalerOf( - context, - ).scale(1.0).clamp(1.0, 1.3), - ), - ), - child: SizedBox( - width: 120, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - _formatLastSeen( - context, - _resolveLastSeen(contact), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (contact.hasLocation) - Icon( - Icons.location_on, - size: 14, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues(alpha: 0.6), - ), - if (contact.rawPacket != null) - const SizedBox(width: 2), - if (contact.rawPacket != null) - Icon( - Icons.cell_tower, - size: 14, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues(alpha: 0.6), - ), - ], - ), - ], - ), + style: MeshTheme.mono( + fontSize: 11, + color: scheme.onSurfaceVariant, ), ), - onTap: () { - connector.importDiscoveredContact(contact); - showDismissibleSnackBar( - context, - content: Text( - context.l10n.discoveredContacts_contactAdded, - ), - action: SnackBarAction( - label: context.l10n.common_undo, - onPressed: () => connector.removeContact(contact), - ), - ); - }, - onLongPress: () => - _showContactContextMenu(contact, connector), - ); - if (PlatformInfo.isDesktop) { - return GestureDetector( - onSecondaryTapUp: (_) => - _showContactContextMenu(contact, connector), - child: tile, - ); - } - return tile; - }, + ), + if (contact.hasLocation) ...[ + const SizedBox(width: 6), + Icon( + Icons.location_on, + size: 13, + color: scheme.onSurfaceVariant.withValues(alpha: 0.55), + ), + ], + if (contact.rawPacket != null) ...[ + const SizedBox(width: 4), + Icon( + Icons.cell_tower, + size: 13, + color: scheme.onSurfaceVariant.withValues(alpha: 0.55), + ), + ], + ], ), - ), - ], + ], + ), + ), + const SizedBox(width: 10), + // Last seen time + MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear( + MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3), + ), + ), + child: Text( + _formatLastSeen(context, _resolveLastSeen(contact)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: MeshTheme.mono( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + ), + ), + ], + ), ), ); } @@ -216,15 +292,18 @@ class _DiscoveryScreenState extends State { Contact contact, MeshCoreConnector connector, ) async { - final action = await showModalBottomSheet( - context: context, - showDragHandle: true, + final action = await showMeshSheet( + context, builder: (sheetContext) { final l10n = context.l10n; return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ + BottomSheetHeader( + title: contact.name, + subtitle: contact.typeLabel(l10n), + ), ListTile( leading: const Icon(Icons.copy), title: Text(l10n.discoveredContacts_copyContact), @@ -235,6 +314,7 @@ class _DiscoveryScreenState extends State { title: Text(l10n.discoveredContacts_deleteContact), onTap: () => Navigator.of(sheetContext).pop('delete_contact'), ), + const SizedBox(height: 8), ], ), ); diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index af80ca22..57d7603f 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -19,6 +19,8 @@ import '../connector/meshcore_connector.dart'; import '../widgets/app_bar.dart'; import '../widgets/quick_switch_bar.dart'; import '../icons/los_icon.dart'; +import '../theme/mesh_theme.dart'; +import '../widgets/themed_map_tile_layer.dart'; class LineOfSightEndpoint { final String label; @@ -30,7 +32,7 @@ class LineOfSightEndpoint { const LineOfSightEndpoint({ required this.label, required this.point, - this.color = Colors.green, + this.color = LosPalette.clear, this.icon = Icons.location_on, this.isCustom = false, }); @@ -59,9 +61,12 @@ class _LineOfSightMapScreenState extends State { static const double _labelZoomThreshold = 8.5; static const double _mapMinZoom = 2.0; static const double _mapMaxZoom = 18.0; + static const double _marginalClearanceMeters = 5.0; final LineOfSightService _lineOfSightService = LineOfSightService(); final MapController _mapController = MapController(); + final DraggableScrollableController _panelController = + DraggableScrollableController(); bool _loading = false; String? _error; @@ -79,6 +84,7 @@ class _LineOfSightMapScreenState extends State { bool _didReceivePositionUpdate = false; int _losRequestNonce = 0; bool _initialLosScheduled = false; + bool _showTerrainLayer = true; @override void initState() { @@ -104,6 +110,7 @@ class _LineOfSightMapScreenState extends State { @override void dispose() { _mapController.dispose(); + _panelController.dispose(); _lineOfSightService.dispose(); super.dispose(); } @@ -140,48 +147,6 @@ class _LineOfSightMapScreenState extends State { _mapController.move(initialCenter, initialZoom); } - Widget _buildDesktopMapControls({ - required LatLng initialCenter, - required double initialZoom, - required LatLngBounds? bounds, - }) { - final screenHeight = MediaQuery.of(context).size.height; - final topOffset = _showHud - ? math.min(screenHeight * 0.52 + 24, screenHeight - 220) - : 12.0; - return Positioned( - top: topOffset, - left: 12, - child: Card( - elevation: 4, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.add), - tooltip: context.l10n.map_zoomIn, - onPressed: () => _zoomMapBy(1), - ), - IconButton( - icon: const Icon(Icons.remove), - tooltip: context.l10n.map_zoomOut, - onPressed: () => _zoomMapBy(-1), - ), - IconButton( - icon: const Icon(Icons.my_location), - tooltip: context.l10n.map_centerMap, - onPressed: () => _resetMapView( - initialCenter: initialCenter, - initialZoom: initialZoom, - bounds: bounds, - ), - ), - ], - ), - ), - ); - } - Future _runLos() async { final start = _start; final end = _end; @@ -224,7 +189,7 @@ class _LineOfSightMapScreenState extends State { setState(() { _result = result; _selectedObstruction = _defaultObstructionFor(result); - _menuExpanded = true; + _menuExpanded = false; }); } catch (e) { if (!mounted) return; @@ -288,7 +253,7 @@ class _LineOfSightMapScreenState extends State { final endpoint = LineOfSightEndpoint( label: context.l10n.losCustomPointLabel(_customEndpoints.length + 1), point: point, - color: Colors.orange, + color: LosPalette.marginal, icon: Icons.push_pin, isCustom: true, ); @@ -415,16 +380,23 @@ class _LineOfSightMapScreenState extends State { title: AppBarTitle(widget.title), centerTitle: true, actions: [ - IconButton( - icon: _loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.delete_outline), - onPressed: _loading ? null : _clearAllPoints, - tooltip: context.l10n.losClearAllPoints, + PopupMenuButton( + onSelected: (value) { + if (value == 'clear') _clearAllPoints(); + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'clear', + enabled: !_loading, + child: Row( + children: [ + const Icon(Icons.delete_outline), + const SizedBox(width: 10), + Text(context.l10n.losClearAllPoints), + ], + ), + ), + ], ), ], ), @@ -470,11 +442,9 @@ class _LineOfSightMapScreenState extends State { }, ), children: [ - TileLayer( - urlTemplate: kMapTileUrlTemplate, - tileProvider: tileCache.tileProvider, - userAgentPackageName: MapTileCacheService.userAgentPackageName, - maxZoom: 19, + ThemedMapTileLayer( + tileCache: tileCache, + opacity: _showTerrainLayer ? 1 : 0.72, ), if (_result != null && _result!.segments.isNotEmpty) PolylineLayer(polylines: _buildSegmentPolylines(_result!)), @@ -483,70 +453,38 @@ class _LineOfSightMapScreenState extends State { ), ], ), - if (isDesktop) - _buildDesktopMapControls( - initialCenter: initialCenter, - initialZoom: initialZoom, - bounds: bounds, - ), + _buildLinkBanner(isImperial), + _buildMapControlRail( + initialCenter: initialCenter, + initialZoom: initialZoom, + bounds: bounds, + isImperial: isImperial, + ), if (_showHud) - Positioned( - left: 12, - right: 12, - top: 12, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.52, - ), - child: _buildControlPanel(isImperial), - ), - ), - if (!_showHud && _result != null && _result!.segments.isNotEmpty) - Positioned( - left: 12, - bottom: 12, - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Text( - context.l10n.losElevationAttribution, - style: TextStyle( - fontSize: 10, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), + DraggableScrollableSheet( + controller: _panelController, + initialChildSize: 0.43, + minChildSize: 0.14, + maxChildSize: 0.88, + snap: true, + snapSizes: const [0.14, 0.43, 0.88], + builder: (context, scrollController) => Theme( + data: MeshTheme.dark(), + child: _buildControlPanel(isImperial, scrollController), ), ), if (_loading) - const Positioned( + Positioned( left: 0, right: 0, top: 0, - child: LinearProgressIndicator(), + child: LinearProgressIndicator( + color: LosPalette.selected, + backgroundColor: LosPalette.chartBackground, + ), ), ], ), - floatingActionButton: FloatingActionButton( - onPressed: () { - setState(() { - _showHud = !_showHud; - }); - }, - tooltip: _showHud - ? context.l10n.losHidePanelTooltip - : context.l10n.losShowPanelTooltip, - child: Icon(_showHud ? Icons.visibility_off : Icons.tune), - ), bottomNavigationBar: SafeArea( top: false, child: QuickSwitchBar( @@ -558,12 +496,477 @@ class _LineOfSightMapScreenState extends State { channelsUnreadCount: context .watch() .getTotalChannelsUnreadCount(), + highContrast: true, ), ), ); } - Widget _buildControlPanel(bool isImperial) { + Widget _buildLinkBanner(bool isImperial) { + final connector = context.watch(); + final segment = _primarySegmentResult(); + final status = _losStatusFor(segment); + final battery = connector.batteryPercent; + final snr = connector.latestRadioStats?.lastSnrDb; + return Positioned( + top: 10, + left: 12, + right: 12, + child: IgnorePointer( + ignoring: false, + child: Material( + color: LosPalette.panelDark, + borderRadius: BorderRadius.circular(MeshRadii.md), + shadowColor: LosPalette.shadow, + elevation: 4, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: _statusColorFor(status).withValues(alpha: 0.18), + shape: BoxShape.circle, + border: Border.all(color: _statusColorFor(status)), + ), + child: Icon( + _statusIcon(status), + color: _statusColorFor(status), + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_start?.label ?? 'A'} → ${_end?.label ?? 'B'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: LosPalette.text, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + segment == null + ? _statusText() + : '${_statusLabel(status)} • ' + '${_formatDistanceValue(segment.totalDistanceMeters, isImperial)} ' + '${isImperial ? 'mi' : 'km'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: LosPalette.textMuted, + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + _headerMetric( + Icons.battery_5_bar, + battery == null ? '--' : '$battery%', + ), + const SizedBox(width: 10), + _headerMetric( + Icons.network_cell, + snr == null ? '--' : '${snr.toStringAsFixed(1)} dB', + ), + ], + ), + ), + ), + ), + ); + } + + Widget _headerMetric(IconData icon, String value) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: LosPalette.textMuted, size: 16), + const SizedBox(height: 2), + Text( + value, + style: MeshTheme.mono( + color: LosPalette.text, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } + + Widget _buildMapControlRail({ + required LatLng initialCenter, + required double initialZoom, + required LatLngBounds? bounds, + required bool isImperial, + }) { + return Positioned( + right: 12, + top: 92, + child: Material( + color: LosPalette.panelDark, + borderRadius: BorderRadius.circular(MeshRadii.md), + clipBehavior: Clip.antiAlias, + elevation: 4, + shadowColor: LosPalette.shadow, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + color: LosPalette.text, + icon: const Icon(Icons.add), + tooltip: context.l10n.map_zoomIn, + onPressed: () => _zoomMapBy(1), + ), + IconButton( + color: LosPalette.text, + icon: const Icon(Icons.remove), + tooltip: context.l10n.map_zoomOut, + onPressed: () => _zoomMapBy(-1), + ), + IconButton( + color: LosPalette.text, + icon: const Icon(Icons.center_focus_strong), + tooltip: context.l10n.map_centerMap, + onPressed: () => _resetMapView( + initialCenter: initialCenter, + initialZoom: initialZoom, + bounds: bounds, + ), + ), + IconButton( + color: _showTerrainLayer + ? LosPalette.selected + : LosPalette.textMuted, + icon: const Icon(Icons.layers_outlined), + tooltip: 'Map detail', + onPressed: () => + setState(() => _showTerrainLayer = !_showTerrainLayer), + ), + IconButton( + color: LosPalette.text, + icon: Text( + isImperial ? 'ft' : 'm', + style: const TextStyle( + color: LosPalette.text, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + tooltip: 'Units', + onPressed: () => context.read().setUnitSystem( + isImperial ? UnitSystem.metric : UnitSystem.imperial, + ), + ), + IconButton( + color: _showHud ? LosPalette.selected : LosPalette.text, + icon: Icon( + _showHud ? Icons.keyboard_arrow_down : Icons.analytics_outlined, + ), + tooltip: _showHud + ? context.l10n.losHidePanelTooltip + : context.l10n.losShowPanelTooltip, + onPressed: () => setState(() => _showHud = !_showHud), + ), + ], + ), + ), + ); + } + + Widget _buildResultSummary(LineOfSightResult? segment, bool isImperial) { + final status = _losStatusFor(segment); + final color = _statusColorFor(status); + final distanceUnit = isImperial ? 'mi' : 'km'; + final heightUnit = isImperial ? 'ft' : 'm'; + final worst = _defaultObstructionFor(_result); + final minClearance = segment == null || segment.samples.isEmpty + ? null + : segment.samples + .map((sample) => sample.clearanceMeters) + .reduce(math.min); + final amount = segment == null + ? '--' + : segment.isClear + ? '${_formatHeightValue(minClearance ?? 0, isImperial)} $heightUnit' + : '${_formatHeightValue(segment.maxObstructionMeters, isImperial)} $heightUnit'; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: color.withValues(alpha: 0.8)), + ), + child: Column( + children: [ + Row( + children: [ + Icon(_statusIcon(status), color: color, size: 24), + const SizedBox(width: 9), + Expanded( + child: Text( + _statusLabel(status), + style: TextStyle( + color: color, + fontSize: 19, + fontWeight: FontWeight.w900, + ), + ), + ), + if (_loading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _summaryMetric( + 'Distance', + segment == null + ? '--' + : '${_formatDistanceValue(segment.totalDistanceMeters, isImperial)} $distanceUnit', + ), + _summaryMetric( + segment?.isClear == true ? 'Clearance' : 'Blocked by', + amount, + valueColor: color, + ), + _summaryMetric( + 'Obstruction', + worst == null + ? '--' + : '${_formatDistanceValue(worst.distanceMeters, isImperial)} $distanceUnit from A', + ), + ], + ), + ], + ), + ); + } + + Widget _summaryMetric(String label, String value, {Color? valueColor}) { + return Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: const TextStyle( + color: LosPalette.textMuted, + fontSize: 9, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + value, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MeshTheme.mono( + color: valueColor ?? LosPalette.text, + fontSize: 11.5, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ); + } + + Widget _buildObstructionCard( + LineOfSightObstruction obstruction, + bool isImperial, { + required bool isWorst, + }) { + final selected = + _selectedObstruction?.sampleIndex == obstruction.sampleIndex; + final distanceUnit = isImperial ? 'mi' : 'km'; + final heightUnit = isImperial ? 'ft' : 'm'; + return InkWell( + onTap: () => _centerOnObstruction(obstruction), + borderRadius: BorderRadius.circular(MeshRadii.sm), + child: Container( + width: 154, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: selected + ? LosPalette.selected.withValues(alpha: 0.18) + : LosPalette.chartBackground, + borderRadius: BorderRadius.circular(MeshRadii.sm), + border: Border.all( + color: selected ? LosPalette.selected : LosPalette.border, + width: selected ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: LosPalette.blocked, + size: 17, + ), + const SizedBox(width: 5), + Expanded( + child: Text( + '${_formatDistanceValue(obstruction.distanceMeters, isImperial)} $distanceUnit', + style: const TextStyle( + color: LosPalette.text, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + if (isWorst) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 2, + ), + decoration: BoxDecoration( + color: LosPalette.blocked, + borderRadius: BorderRadius.circular(99), + ), + child: const Text( + 'WORST', + style: TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + const Spacer(), + Text( + 'Blocked ${_formatHeightValue(obstruction.obstructionMeters, isImperial)} $heightUnit', + style: const TextStyle( + color: LosPalette.textMuted, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } + + Widget _buildSelectedObstructionCard( + LineOfSightObstruction obstruction, + LineOfSightResult segment, + bool isImperial, + ) { + final distanceUnit = isImperial ? 'mi' : 'km'; + final heightUnit = isImperial ? 'ft' : 'm'; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: LosPalette.selected.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: LosPalette.selected), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Selected obstruction', + style: TextStyle( + color: LosPalette.text, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 18, + runSpacing: 10, + children: [ + _detailValue( + 'Blocked by', + '${_formatHeightValue(obstruction.obstructionMeters, isImperial)} $heightUnit', + ), + _detailValue( + 'From A', + '${_formatDistanceValue(obstruction.distanceMeters, isImperial)} $distanceUnit', + ), + _detailValue( + 'From B', + '${_formatDistanceValue(segment.totalDistanceMeters - obstruction.distanceMeters, isImperial)} $distanceUnit', + ), + _detailValue( + 'Elevation', + '${_formatHeightValue(obstruction.terrainMeters, isImperial)} $heightUnit', + ), + ], + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: () => _centerOnObstruction(obstruction), + icon: const Icon(Icons.center_focus_strong, size: 17), + label: const Text('Center on map'), + ), + ), + ], + ), + ); + } + + Widget _detailValue(String label, String value) { + return SizedBox( + width: 120, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: const TextStyle( + color: LosPalette.textMuted, + fontSize: 9, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: MeshTheme.mono( + color: LosPalette.text, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + Widget _buildControlPanel( + bool isImperial, + ScrollController scrollController, + ) { _sanitizeSelection(); final segment = _primarySegmentResult(); final connector = context.read(); @@ -583,363 +986,265 @@ class _LineOfSightMapScreenState extends State { final antennaBDisplay = _toDisplayHeight(antennaBMeters, isImperial); final antennaSliderMax = isImperial ? _maxAntennaFeet : _maxAntennaMeters; final antennaSliderDivisions = isImperial ? 400 : 122; - return Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + final worst = _defaultObstructionFor(_result); + return Material( + color: LosPalette.panelDark, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(MeshRadii.lg), + ), + clipBehavior: Clip.antiAlias, + child: ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 28), + children: [ + Center( + child: Container( + width: 44, + height: 5, + decoration: BoxDecoration( + color: LosPalette.textMuted.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(99), + ), + ), + ), + const SizedBox(height: 10), + _buildResultSummary(segment, isImperial), + if (segment != null) ...[ + const SizedBox(height: 14), + _buildProfileView(segment, distanceUnit, heightUnit, isImperial), + const SizedBox(height: 10), + _LosLegend( + terrainLabel: context.l10n.losLegendTerrain, + losBeamLabel: context.l10n.losLegendLosBeam, + radioHorizonLabel: context.l10n.losLegendRadioHorizon, + ), + ], + if (obstructions.isNotEmpty) ...[ + const SizedBox(height: 18), + Text( + context.l10n.losBlockedSpotsTitle, + style: const TextStyle( + color: LosPalette.text, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + context.l10n.losBlockedSpotsHint, + style: const TextStyle(color: LosPalette.textMuted, fontSize: 12), + ), + const SizedBox(height: 10), + SizedBox( + height: 86, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: obstructions.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final obstruction = obstructions[index]; + return _buildObstructionCard( + obstruction, + isImperial, + isWorst: obstruction.sampleIndex == worst?.sampleIndex, + ); + }, + ), + ), + ], + if (_selectedObstruction != null && segment != null) ...[ + const SizedBox(height: 14), + _buildSelectedObstructionCard( + _selectedObstruction!, + segment, + isImperial, + ), + ], + const SizedBox(height: 12), + ExpansionTile( + initiallyExpanded: _menuExpanded, + onExpansionChanged: (value) => + setState(() => _menuExpanded = value), + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + iconColor: LosPalette.text, + collapsedIconColor: LosPalette.textMuted, + title: Text( + context.l10n.losMenuTitle, + style: const TextStyle( + color: LosPalette.text, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + subtitle: Text( + context.l10n.losMenuSubtitle, + style: const TextStyle(color: LosPalette.textMuted, fontSize: 11), + ), children: [ - if (segment != null) - _buildProfileView(segment, distanceUnit, heightUnit, isImperial) - else - SizedBox( - height: 44, - child: Center( - child: Text( - context.l10n.losRunToViewElevationProfile, - style: const TextStyle(fontSize: 11), - ), - ), + SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text( + context.l10n.losShowDisplayNodes, + style: const TextStyle(fontSize: 12), ), - if (segment != null) ...[ - const SizedBox(height: 8), - _LosLegend( - terrainLabel: context.l10n.losLegendTerrain, - losBeamLabel: context.l10n.losLegendLosBeam, - radioHorizonLabel: context.l10n.losLegendRadioHorizon, - ), - ], - const SizedBox(height: 8), - Text( - segment != null - ? _profileStats(segment, isImperial) - : _statusText(), - style: TextStyle( - fontSize: 12, - color: segment != null - ? (segment.isClear ? Colors.green : Colors.red) - : _statusColor(), - fontWeight: FontWeight.w600, - ), - ), - if (obstructions.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - context.l10n.losBlockedSpotsTitle, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - context.l10n.losBlockedSpotsHint, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 6), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - for (final obstruction in obstructions) - ChoiceChip( - label: Text( - _obstructionChipLabel(obstruction, isImperial), - style: const TextStyle(fontSize: 11), - ), - selected: - _selectedObstruction?.sampleIndex == - obstruction.sampleIndex, - onSelected: (_) => _selectObstruction(obstruction), - ), - ], - ), - if (_selectedObstruction != null) ...[ - const SizedBox(height: 8), - DecoratedBox( - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Colors.deepOrangeAccent.withValues(alpha: 0.45), - ), - ), - child: Padding( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.losSelectedObstructionTitle, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - context.l10n.losSelectedObstructionDetails( - _formatHeightValue( - _selectedObstruction!.obstructionMeters, - isImperial, - ), - heightUnit, - _formatDistanceValue( - _selectedObstruction!.distanceMeters, - isImperial, - ), - distanceUnit, - _formatDistanceValue( - segment!.totalDistanceMeters - - _selectedObstruction!.distanceMeters, - isImperial, - ), - ), - style: const TextStyle(fontSize: 11), - ), - const SizedBox(height: 4), - Text( - '${_selectedObstruction!.point.latitude.toStringAsFixed(5)}, ' - '${_selectedObstruction!.point.longitude.toStringAsFixed(5)}', - style: TextStyle( - fontSize: 11, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ], - ], - const SizedBox(height: 4), - if (displayFrequencyMHz != null) - Padding( - padding: const EdgeInsets.only(top: 2, bottom: 4), - child: Row( - children: [ - Text( - context.l10n.losFrequencyLabel, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 8), - Text( - '${displayFrequencyMHz.toStringAsFixed(3)} MHz', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - if (kFactorUsed != null) ...[ - const SizedBox(width: 8), - Text( - 'k=${kFactorUsed.toStringAsFixed(3)}', - style: TextStyle( - fontSize: 11, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 4), - IconButton( - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - icon: const Icon(Icons.info_outline, size: 16), - color: Theme.of(context).colorScheme.onSurfaceVariant, - tooltip: context.l10n.losFrequencyInfoTooltip, - onPressed: () { - _showFrequencyInfoDialog( - context, - displayFrequencyMHz, - kFactorUsed, - ); - }, - ), - ], - ], - ), - ), - Text( - context.l10n.losElevationAttribution, - style: TextStyle( - fontSize: 10, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 6), - ExpansionTile( - initiallyExpanded: _menuExpanded, - onExpansionChanged: (value) { + value: _showDisplayNodes, + onChanged: (value) { setState(() { - _menuExpanded = value; + _showDisplayNodes = value; + _sanitizeSelection(); + _result = null; + _selectedObstruction = null; }); }, - tilePadding: EdgeInsets.zero, - childrenPadding: EdgeInsets.zero, - title: Text( - context.l10n.losMenuTitle, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - ), + ), + if (_customEndpoints.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + context.l10n.losCustomPoints, + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), ), - subtitle: Text( - context.l10n.losMenuSubtitle, - style: const TextStyle(fontSize: 11), - ), - children: [ - SwitchListTile( + for (final point in _customEndpoints) + ListTile( dense: true, contentPadding: EdgeInsets.zero, title: Text( - context.l10n.losShowDisplayNodes, + point.label, style: const TextStyle(fontSize: 12), ), - value: _showDisplayNodes, - onChanged: (value) { - setState(() { - _showDisplayNodes = value; - _sanitizeSelection(); - _result = null; - _selectedObstruction = null; - }); - }, - ), - if (_customEndpoints.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - context.l10n.losCustomPoints, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - ), + subtitle: Text( + '${point.point.latitude.toStringAsFixed(5)}, ${point.point.longitude.toStringAsFixed(5)}', + style: const TextStyle(fontSize: 11), ), - for (final point in _customEndpoints) - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - title: Text( - point.label, - style: const TextStyle(fontSize: 12), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, size: 18), + onPressed: () => _renameCustomPoint(point), + tooltip: context.l10n.common_edit, ), - subtitle: Text( - '${point.point.latitude.toStringAsFixed(5)}, ${point.point.longitude.toStringAsFixed(5)}', - style: const TextStyle(fontSize: 11), + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: () => _deleteCustomPoint(point), + tooltip: context.l10n.common_delete, ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit, size: 18), - onPressed: () => _renameCustomPoint(point), - tooltip: context.l10n.common_edit, - ), - IconButton( - icon: const Icon(Icons.delete_outline, size: 18), - onPressed: () => _deleteCustomPoint(point), - tooltip: context.l10n.common_delete, - ), - ], - ), - ), - ], - const SizedBox(height: 8), - _buildEndpointRow( - label: context.l10n.losPointA, - value: _start, - candidates: endpoints, - onChanged: (value) { - setState(() { - _start = value; - _result = null; - _selectedObstruction = null; - }); - if (_start != null && _end != null) { - _runLos(); - } - }, - ), - const SizedBox(height: 8), - _buildEndpointRow( - label: context.l10n.losPointB, - value: _end, - candidates: endpoints, - onChanged: (value) { - setState(() { - _end = value; - _result = null; - _selectedObstruction = null; - }); - if (_start != null && _end != null) { - _runLos(); - } - }, - ), - const SizedBox(height: 10), - Text( - context.l10n.losAntennaA( - antennaADisplay.toStringAsFixed(1), - heightUnit, - ), - style: const TextStyle(fontSize: 12), - ), - Slider( - value: antennaADisplay, - min: 0, - max: antennaSliderMax, - divisions: antennaSliderDivisions, - onChanged: (value) { - setState(() { - _startAntennaHeight = _toMetersHeight( - value, - isImperial, - ); - }); - }, - ), - Text( - context.l10n.losAntennaB( - antennaBDisplay.toStringAsFixed(1), - heightUnit, - ), - style: const TextStyle(fontSize: 12), - ), - Slider( - value: antennaBDisplay, - min: 0, - max: antennaSliderMax, - divisions: antennaSliderDivisions, - onChanged: (value) { - setState(() { - _endAntennaHeight = _toMetersHeight(value, isImperial); - }); - }, - ), - Align( - alignment: Alignment.centerRight, - child: ElevatedButton.icon( - onPressed: _loading ? null : _runLos, - icon: const LosIcon(), - label: Text(context.l10n.losRun), + ], ), ), - ], + ], + const SizedBox(height: 8), + _buildEndpointRow( + label: context.l10n.losPointA, + value: _start, + candidates: endpoints, + onChanged: (value) { + setState(() { + _start = value; + _result = null; + _selectedObstruction = null; + }); + if (_start != null && _end != null) { + _runLos(); + } + }, + ), + const SizedBox(height: 8), + _buildEndpointRow( + label: context.l10n.losPointB, + value: _end, + candidates: endpoints, + onChanged: (value) { + setState(() { + _end = value; + _result = null; + _selectedObstruction = null; + }); + if (_start != null && _end != null) { + _runLos(); + } + }, + ), + const SizedBox(height: 10), + Text( + context.l10n.losAntennaA( + antennaADisplay.toStringAsFixed(1), + heightUnit, + ), + style: const TextStyle(fontSize: 12), + ), + Slider( + value: antennaADisplay, + min: 0, + max: antennaSliderMax, + divisions: antennaSliderDivisions, + onChanged: (value) { + setState(() { + _startAntennaHeight = _toMetersHeight(value, isImperial); + }); + }, + ), + Text( + context.l10n.losAntennaB( + antennaBDisplay.toStringAsFixed(1), + heightUnit, + ), + style: const TextStyle(fontSize: 12), + ), + Slider( + value: antennaBDisplay, + min: 0, + max: antennaSliderMax, + divisions: antennaSliderDivisions, + onChanged: (value) { + setState(() { + _endAntennaHeight = _toMetersHeight(value, isImperial); + }); + }, + ), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: _loading ? null : _runLos, + icon: const LosIcon(), + label: Text(context.l10n.losRun), + ), ), ], ), - ), + if (displayFrequencyMHz != null) ...[ + const SizedBox(height: 10), + Row( + children: [ + Text( + '${context.l10n.losFrequencyLabel}: ' + '${displayFrequencyMHz.toStringAsFixed(3)} MHz' + '${kFactorUsed == null ? '' : ' k=${kFactorUsed.toStringAsFixed(3)}'}', + style: const TextStyle( + color: LosPalette.textMuted, + fontSize: 11, + ), + ), + if (kFactorUsed != null) + IconButton( + icon: const Icon(Icons.info_outline, size: 17), + color: LosPalette.textMuted, + tooltip: context.l10n.losFrequencyInfoTooltip, + onPressed: () => _showFrequencyInfoDialog( + context, + displayFrequencyMHz, + kFactorUsed, + ), + ), + ], + ), + ], + Text( + context.l10n.losElevationAttribution, + style: const TextStyle(color: LosPalette.textMuted, fontSize: 10), + ), + ], ), ); } @@ -1002,6 +1307,14 @@ class _LineOfSightMapScreenState extends State { }); } + void _centerOnObstruction(LineOfSightObstruction obstruction) { + _selectObstruction(obstruction); + _mapController.move( + obstruction.point, + math.max(_mapController.camera.zoom, 15), + ); + } + String _formatDistanceValue(double meters, bool isImperial) { final value = isImperial ? (meters / 1000.0) * _kmToMiles : meters / 1000.0; return value.toStringAsFixed(2); @@ -1034,7 +1347,7 @@ class _LineOfSightMapScreenState extends State { ) { if (segment.samples.length < 2) { return SizedBox( - height: 160, + height: 190, width: double.infinity, child: CustomPaint( painter: _LosProfilePainter( @@ -1043,12 +1356,12 @@ class _LineOfSightMapScreenState extends State { heightUnit: heightUnit, badgeTextStyle: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.white70, + color: LosPalette.textMuted, fontSize: 10, fontWeight: FontWeight.w600, ) ?? const TextStyle( - color: Colors.white70, + color: LosPalette.textMuted, fontSize: 10, fontWeight: FontWeight.w600, ), @@ -1061,11 +1374,11 @@ class _LineOfSightMapScreenState extends State { ); } return SizedBox( - height: 160, + height: 190, width: double.infinity, child: LayoutBuilder( builder: (context, constraints) { - final size = Size(constraints.maxWidth, 160); + final size = Size(constraints.maxWidth, 190); final geometry = _LosProfileGeometry( samples: segment.samples, size: size, @@ -1081,12 +1394,12 @@ class _LineOfSightMapScreenState extends State { heightUnit: heightUnit, badgeTextStyle: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.white70, + color: LosPalette.textMuted, fontSize: 10, fontWeight: FontWeight.w600, ) ?? const TextStyle( - color: Colors.white70, + color: LosPalette.textMuted, fontSize: 10, fontWeight: FontWeight.w600, ), @@ -1121,23 +1434,26 @@ class _LineOfSightMapScreenState extends State { child: Tooltip( message: _obstructionChipLabel(obstruction, isImperial), child: GestureDetector( - onTap: () => _selectObstruction(obstruction), + onTap: () => _centerOnObstruction(obstruction), child: Container( width: markerSize, height: markerSize, decoration: BoxDecoration( color: isSelected - ? Colors.amberAccent - : Colors.deepOrangeAccent, + ? LosPalette.selected + : LosPalette.blocked, shape: BoxShape.circle, border: Border.all( color: isSelected - ? Colors.white - : Colors.black87, + ? LosPalette.text + : LosPalette.chartBackground, width: isSelected ? 2 : 1.5, ), boxShadow: const [ - BoxShadow(color: Colors.black45, blurRadius: 4), + BoxShadow( + color: LosPalette.shadow, + blurRadius: 4, + ), ], ), ), @@ -1153,51 +1469,19 @@ class _LineOfSightMapScreenState extends State { ); } - String _profileStats(LineOfSightResult result, bool isImperial) { - final distance = isImperial - ? (result.totalDistanceMeters / 1000.0) * _kmToMiles - : result.totalDistanceMeters / 1000.0; - final distanceUnit = isImperial ? 'mi' : 'km'; - final heightUnit = isImperial ? 'ft' : 'm'; - final minClearance = result.samples.isEmpty - ? 0.0 - : result.samples.map((s) => s.clearanceMeters).reduce(math.min); - final minClearanceDisplay = isImperial - ? minClearance * _metersToFeet - : minClearance; - final maxObstructionDisplay = isImperial - ? result.maxObstructionMeters * _metersToFeet - : result.maxObstructionMeters; - if (!result.hasData) { - return _localizedLosError(result.errorMessage); - } - if (result.isClear) { - return context.l10n.losProfileClear( - distance.toStringAsFixed(1), - distanceUnit, - minClearanceDisplay.toStringAsFixed(1), - heightUnit, - ); - } - return context.l10n.losProfileBlocked( - distance.toStringAsFixed(1), - distanceUnit, - maxObstructionDisplay.toStringAsFixed(1), - heightUnit, - ); - } - List _buildSegmentPolylines(LineOfSightPathResult result) { final polylines = []; for (final segment in result.segments) { final color = !segment.result.hasData - ? Colors.grey - : (segment.result.isClear ? Colors.green : Colors.red); + ? LosPalette.textMuted + : _statusColorFor(_losStatusFor(segment.result)); polylines.add( Polyline( points: [segment.start, segment.end], - strokeWidth: 4, + strokeWidth: 5, color: color, + borderStrokeWidth: 2, + borderColor: Colors.white, ), ); } @@ -1215,7 +1499,7 @@ class _LineOfSightMapScreenState extends State { width: 52, height: 52, child: GestureDetector( - onTap: () => _selectObstruction(obstruction), + onTap: () => _centerOnObstruction(obstruction), child: Center( child: Container( width: @@ -1233,16 +1517,20 @@ class _LineOfSightMapScreenState extends State { color: _selectedObstruction?.sampleIndex == obstruction.sampleIndex - ? Colors.amberAccent - : Colors.deepOrangeAccent, + ? LosPalette.selected + : LosPalette.blocked, width: _selectedObstruction?.sampleIndex == obstruction.sampleIndex ? 4 : 3, ), - boxShadow: const [ - BoxShadow(color: Colors.black26, blurRadius: 6), + boxShadow: [ + const BoxShadow( + color: LosPalette.shadow, + blurRadius: 8, + offset: Offset(0, 2), + ), ], ), ), @@ -1258,17 +1546,34 @@ class _LineOfSightMapScreenState extends State { onTap: () => _selectFromMap(endpoint), child: Container( decoration: BoxDecoration( - color: endpoint.color, shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), + color: (endpoint == _start || endpoint == _end) + ? endpoint.color + : LosPalette.panelDark, + border: Border.all( + color: (endpoint == _start || endpoint == _end) + ? Colors.white + : endpoint.color.withValues(alpha: 0.75), + width: (endpoint == _start || endpoint == _end) ? 2.5 : 1.5, + ), boxShadow: const [ - BoxShadow(color: Colors.black26, blurRadius: 4), + BoxShadow( + color: LosPalette.shadow, + blurRadius: 7, + offset: Offset(0, 2), + ), ], ), child: Stack( children: [ Center( - child: Icon(endpoint.icon, color: Colors.white, size: 16), + child: Icon( + endpoint.icon, + color: endpoint == _start || endpoint == _end + ? Colors.white + : endpoint.color, + size: 17, + ), ), if (endpoint == _start || endpoint == _end) Positioned( @@ -1278,17 +1583,17 @@ class _LineOfSightMapScreenState extends State { width: 14, height: 14, decoration: BoxDecoration( - color: Colors.black87, + color: LosPalette.chartBackground, borderRadius: BorderRadius.circular(7), - border: Border.all(color: Colors.white, width: 1), + border: Border.all(color: endpoint.color, width: 1), ), alignment: Alignment.center, child: Text( endpoint == _start ? 'A' : 'B', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, + style: MeshTheme.mono( fontSize: 9, + fontWeight: FontWeight.w700, + color: endpoint.color, ), ), ), @@ -1316,18 +1621,19 @@ class _LineOfSightMapScreenState extends State { vertical: 2, ), decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), + color: LosPalette.panelDark, + borderRadius: BorderRadius.circular(MeshRadii.xs), + border: Border.all(color: LosPalette.border), ), alignment: Alignment.center, child: Text( endpoint.label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w500, + style: MeshTheme.mono( + fontSize: 10, + fontWeight: FontWeight.w700, + color: LosPalette.text, ), ), ), @@ -1354,13 +1660,55 @@ class _LineOfSightMapScreenState extends State { ); } - Color _statusColor() { - if (_error != null) return Colors.red; - if (_loading) return Colors.orange; - if (_result == null) return Colors.grey; - if (_result!.blockedSegments > 0) return Colors.red; - if (_result!.clearSegments > 0) return Colors.green; - return Colors.grey; + _LosDisplayStatus _losStatusFor(LineOfSightResult? result) { + if (result == null || !result.hasData) return _LosDisplayStatus.unknown; + if (!result.isClear) return _LosDisplayStatus.blocked; + if (result.samples.isEmpty) return _LosDisplayStatus.clear; + final minClearance = result.samples + .map((sample) => sample.clearanceMeters) + .reduce(math.min); + return minClearance <= _marginalClearanceMeters + ? _LosDisplayStatus.marginal + : _LosDisplayStatus.clear; + } + + String _statusLabel(_LosDisplayStatus status) { + switch (status) { + case _LosDisplayStatus.clear: + return 'Clear'; + case _LosDisplayStatus.marginal: + return 'Marginal'; + case _LosDisplayStatus.blocked: + return 'Blocked'; + case _LosDisplayStatus.unknown: + return _loading ? 'Checking' : 'No result'; + } + } + + Color _statusColorFor(_LosDisplayStatus status) { + switch (status) { + case _LosDisplayStatus.clear: + return LosPalette.clear; + case _LosDisplayStatus.marginal: + return LosPalette.marginal; + case _LosDisplayStatus.blocked: + return LosPalette.blocked; + case _LosDisplayStatus.unknown: + return LosPalette.textMuted; + } + } + + IconData _statusIcon(_LosDisplayStatus status) { + switch (status) { + case _LosDisplayStatus.clear: + return Icons.check_circle; + case _LosDisplayStatus.marginal: + return Icons.warning_amber_rounded; + case _LosDisplayStatus.blocked: + return Icons.block; + case _LosDisplayStatus.unknown: + return Icons.help_outline; + } } double _toDisplayHeight(double meters, bool isImperial) { @@ -1371,16 +1719,6 @@ class _LineOfSightMapScreenState extends State { return isImperial ? displayHeight / _metersToFeet : displayHeight; } - String _localizedLosError(String? message) { - if (message == LineOfSightService.errorElevationUnavailable) { - return context.l10n.losErrorElevationUnavailable; - } - if (message == LineOfSightService.errorInvalidInput) { - return context.l10n.losErrorInvalidInput; - } - return context.l10n.losNoElevationData; - } - void _handleQuickSwitch(int index, BuildContext context) { if (index == 2) { Navigator.pop(context); @@ -1437,9 +1775,13 @@ class _LineOfSightMapScreenState extends State { } } +enum _LosDisplayStatus { clear, marginal, blocked, unknown } + class _LosProfileGeometry { - static const horizontalPadding = 12.0; - static const verticalPadding = 12.0; + static const leftPadding = 38.0; + static const rightPadding = 14.0; + static const topPadding = 20.0; + static const bottomPadding = 28.0; final List samples; final Size size; @@ -1463,20 +1805,20 @@ class _LosProfileGeometry { late final double maxDist = math.max(1.0, samples.last.distanceMeters); late final double chartWidth = math.max( 1.0, - size.width - horizontalPadding * 2, + size.width - leftPadding - rightPadding, ); late final double chartHeight = math.max( 1.0, - size.height - verticalPadding * 2, + size.height - topPadding - bottomPadding, ); _LosProfileGeometry({required this.samples, required this.size}); Offset mapPoint(double distanceMeters, double elevationMeters) { - final px = horizontalPadding + (distanceMeters / maxDist) * chartWidth; + final px = leftPadding + (distanceMeters / maxDist) * chartWidth; final py = size.height - - verticalPadding - + bottomPadding - ((elevationMeters - minY) / ySpan) * chartHeight; return Offset(px, py); } @@ -1505,7 +1847,7 @@ class _LosProfilePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final bg = Paint()..color = const Color(0xFF243A63); + final bg = Paint()..color = LosPalette.chartBackground; canvas.drawRect(Offset.zero & size, bg); _drawUnitBadge(canvas, size); @@ -1529,18 +1871,52 @@ class _LosProfilePainter extends CustomPainter { .reduce(math.max); final ySpan = math.max(1.0, maxY - minY); final maxDist = math.max(1.0, samples.last.distanceMeters); - const horizontalPadding = 12.0; - const verticalPadding = 12.0; - final chartWidth = math.max(1.0, size.width - horizontalPadding * 2); - final chartHeight = math.max(1.0, size.height - verticalPadding * 2); + const leftPadding = _LosProfileGeometry.leftPadding; + const rightPadding = _LosProfileGeometry.rightPadding; + const topPadding = _LosProfileGeometry.topPadding; + const bottomPadding = _LosProfileGeometry.bottomPadding; + final chartWidth = math.max(1.0, size.width - leftPadding - rightPadding); + final chartHeight = math.max(1.0, size.height - topPadding - bottomPadding); Offset mapPoint(double x, double y) { - final px = horizontalPadding + (x / maxDist) * chartWidth; + final px = leftPadding + (x / maxDist) * chartWidth; final py = - size.height - verticalPadding - ((y - minY) / ySpan) * chartHeight; + size.height - bottomPadding - ((y - minY) / ySpan) * chartHeight; return Offset(px, py); } + final gridPaint = Paint() + ..color = LosPalette.textMuted.withValues(alpha: 0.16) + ..strokeWidth = 1; + for (var i = 0; i <= 4; i++) { + final x = leftPadding + chartWidth * i / 4; + final y = topPadding + chartHeight * i / 4; + canvas.drawLine( + Offset(x, topPadding), + Offset(x, size.height - bottomPadding), + gridPaint, + ); + canvas.drawLine( + Offset(leftPadding, y), + Offset(size.width - rightPadding, y), + gridPaint, + ); + final distance = maxDist * i / 4; + _paintLabel( + canvas, + _displayDistance(distance).toStringAsFixed(i == 0 ? 0 : 1), + Offset(x, size.height - bottomPadding + 7), + center: true, + ); + final elevation = maxY - ySpan * i / 4; + _paintLabel( + canvas, + _displayHeight(elevation).toStringAsFixed(0), + Offset(leftPadding - 6, y - 6), + alignRight: true, + ); + } + final firstTerrainPoint = mapPoint( samples.first.distanceMeters, samples.first.terrainMeters, @@ -1551,13 +1927,13 @@ class _LosProfilePainter extends CustomPainter { ); double distanceForCanvasX(double x) { - final normalized = ((x - horizontalPadding) / chartWidth).clamp(0.0, 1.0); + final normalized = ((x - leftPadding) / chartWidth).clamp(0.0, 1.0); return normalized * maxDist; } double elevationToPixel(double elevation) { final normalized = ((elevation - minY) / ySpan).clamp(0.0, 1.0); - return size.height - verticalPadding - normalized * chartHeight; + return size.height - bottomPadding - normalized * chartHeight; } double extrapolateTerrain(double distance, bool isLeft) { @@ -1599,10 +1975,10 @@ class _LosProfilePainter extends CustomPainter { ..lineTo(size.width, size.height) ..close(); - const terrainFillColor = Color(0xCC7C6F5D); - const terrainLineColor = Color(0xFF9FE870); - const losLineColor = Color(0xFFE0E7FF); - canvas.drawPath(terrainPath, Paint()..color = terrainFillColor); + canvas.drawPath( + terrainPath, + Paint()..color = LosPalette.terrain.withValues(alpha: 0.18), + ); final terrainLine = ui.Path()..moveTo(leftEdgePoint.dx, leftEdgePoint.dy); for (final sample in samples) { @@ -1613,9 +1989,9 @@ class _LosProfilePainter extends CustomPainter { canvas.drawPath( terrainLine, Paint() - ..color = terrainLineColor + ..color = LosPalette.terrain ..style = PaintingStyle.stroke - ..strokeWidth = 2, + ..strokeWidth = 2.5, ); final losLine = ui.Path(); @@ -1633,12 +2009,11 @@ class _LosProfilePainter extends CustomPainter { canvas.drawPath( losLine, Paint() - ..color = losLineColor + ..color = LosPalette.beam ..style = PaintingStyle.stroke - ..strokeWidth = 2, + ..strokeWidth = 2.5, ); - const refractedLineColor = Color(0xFFFFD57F); final refractedLine = ui.Path(); for (int i = 0; i < samples.length; i++) { final p = mapPoint( @@ -1654,7 +2029,7 @@ class _LosProfilePainter extends CustomPainter { canvas.drawPath( refractedLine, Paint() - ..color = refractedLineColor + ..color = LosPalette.horizon ..style = PaintingStyle.stroke ..strokeWidth = 1.5, ); @@ -1679,14 +2054,53 @@ class _LosProfilePainter extends CustomPainter { capPath.lineTo(p.dx, p.dy); } capPath.close(); - const horizonFillColor = Color(0x40FFD57F); canvas.drawPath( capPath, Paint() - ..color = horizonFillColor + ..color = LosPalette.horizon.withValues(alpha: 0.10) ..style = PaintingStyle.fill, ); + for (var i = 0; i < samples.length - 1; i++) { + if (samples[i].clearanceMeters >= 0 && + samples[i + 1].clearanceMeters >= 0) { + continue; + } + final terrainA = mapPoint( + samples[i].distanceMeters, + samples[i].terrainMeters, + ); + final terrainB = mapPoint( + samples[i + 1].distanceMeters, + samples[i + 1].terrainMeters, + ); + final lineB = mapPoint( + samples[i + 1].distanceMeters, + samples[i + 1].lineHeightMeters, + ); + final lineA = mapPoint( + samples[i].distanceMeters, + samples[i].lineHeightMeters, + ); + final blockedArea = ui.Path() + ..moveTo(terrainA.dx, terrainA.dy) + ..lineTo(terrainB.dx, terrainB.dy) + ..lineTo(lineB.dx, lineB.dy) + ..lineTo(lineA.dx, lineA.dy) + ..close(); + canvas.drawPath( + blockedArea, + Paint()..color = LosPalette.blocked.withValues(alpha: 0.42), + ); + } + + _paintEndpoint(canvas, mapPoint(0, samples.first.lineHeightMeters), 'A'); + _paintEndpoint( + canvas, + mapPoint(maxDist, samples.last.lineHeightMeters), + 'B', + ); + if (selectedSampleIndex != null && selectedSampleIndex! >= 0 && selectedSampleIndex! < samples.length) { @@ -1696,24 +2110,103 @@ class _LosProfilePainter extends CustomPainter { selectedSample.terrainMeters, ); canvas.drawLine( - Offset(selectedPoint.dx, verticalPadding), - Offset(selectedPoint.dx, size.height - verticalPadding), + Offset(selectedPoint.dx, topPadding), + Offset(selectedPoint.dx, size.height - bottomPadding), Paint() - ..color = Colors.amberAccent.withValues(alpha: 0.7) - ..strokeWidth = 1.5, + ..color = LosPalette.selected + ..strokeWidth = 2, ); - canvas.drawCircle(selectedPoint, 7, Paint()..color = Colors.amberAccent); + canvas.drawCircle(selectedPoint, 7, Paint()..color = LosPalette.selected); canvas.drawCircle( selectedPoint, 8.5, Paint() - ..color = Colors.white + ..color = LosPalette.text ..style = PaintingStyle.stroke ..strokeWidth = 1.5, ); + final labelY = math.max(topPadding + 2, selectedPoint.dy - 27); + _paintPill( + canvas, + 'Selected', + Offset( + selectedPoint.dx.clamp(42.0, size.width - 42).toDouble(), + labelY, + ), + ); } } + double _displayDistance(double meters) { + return distanceUnit == 'mi' + ? (meters / 1000.0) * 0.621371 + : meters / 1000.0; + } + + double _displayHeight(double meters) { + return heightUnit == 'ft' ? meters * 3.28084 : meters; + } + + void _paintLabel( + Canvas canvas, + String text, + Offset offset, { + bool center = false, + bool alignRight = false, + }) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle( + color: LosPalette.textMuted, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + var dx = offset.dx; + if (center) dx -= painter.width / 2; + if (alignRight) dx -= painter.width; + painter.paint(canvas, Offset(dx, offset.dy)); + } + + void _paintEndpoint(Canvas canvas, Offset point, String label) { + canvas.drawCircle(point, 9, Paint()..color = LosPalette.chartBackground); + canvas.drawCircle( + point, + 9, + Paint() + ..color = LosPalette.beam + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + _paintLabel(canvas, label, Offset(point.dx, point.dy - 5), center: true); + } + + void _paintPill(Canvas canvas, String text, Offset center) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle( + color: LosPalette.text, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + final rect = RRect.fromRectAndRadius( + Rect.fromCenter(center: center, width: painter.width + 12, height: 20), + const Radius.circular(10), + ); + canvas.drawRRect(rect, Paint()..color = LosPalette.selected); + painter.paint( + canvas, + Offset(center.dx - painter.width / 2, center.dy - painter.height / 2), + ); + } + @override bool shouldRepaint(covariant _LosProfilePainter oldDelegate) { return oldDelegate.samples != samples || @@ -1738,10 +2231,6 @@ class _LosProfilePainter extends CustomPainter { } class _LosLegend extends StatelessWidget { - static const _terrainColor = Color(0xFF9FE870); - static const _losColor = Color(0xFFE0E7FF); - static const _radioColor = Color(0xFFFFD57F); - final String terrainLabel; final String losBeamLabel; final String radioHorizonLabel; @@ -1756,23 +2245,24 @@ class _LosLegend extends StatelessWidget { Widget build(BuildContext context) { final textStyle = Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 11, - fontWeight: FontWeight.w500, + color: LosPalette.text, + fontSize: 12, + fontWeight: FontWeight.w700, ) ?? - TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 11, - fontWeight: FontWeight.w500, + const TextStyle( + color: LosPalette.text, + fontSize: 12, + fontWeight: FontWeight.w700, ); final entries = [ - _LegendEntry(terrainLabel, _terrainColor), - _LegendEntry(losBeamLabel, _losColor), - _LegendEntry(radioHorizonLabel, _radioColor), + _LegendEntry(terrainLabel, LosPalette.terrain), + _LegendEntry(losBeamLabel, LosPalette.beam), + _LegendEntry(radioHorizonLabel, LosPalette.horizon), + const _LegendEntry('Blocked', LosPalette.blocked), ]; - const swatchSize = 10.0; + const swatchSize = 12.0; return Wrap( spacing: 16, diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart index e627bd58..6455c15c 100644 --- a/lib/screens/map_cache_screen.dart +++ b/lib/screens/map_cache_screen.dart @@ -10,6 +10,9 @@ import '../services/app_settings_service.dart'; import '../services/map_tile_cache_service.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../helpers/snack_bar_builder.dart'; +import '../theme/mesh_theme.dart'; +import '../widgets/mesh_ui.dart'; +import '../widgets/themed_map_tile_layer.dart'; class MapCacheScreen extends StatefulWidget { const MapCacheScreen({super.key}); @@ -76,27 +79,34 @@ class _MapCacheScreenState extends State { return Positioned( top: 12, left: 12, - child: Card( - elevation: 4, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.add), - tooltip: context.l10n.map_zoomIn, - onPressed: () => _zoomMapBy(1), - ), - IconButton( - icon: const Icon(Icons.remove), - tooltip: context.l10n.map_zoomOut, - onPressed: () => _zoomMapBy(-1), - ), - IconButton( - icon: const Icon(Icons.my_location), - tooltip: context.l10n.map_centerMap, - onPressed: _resetMapView, - ), - ], + child: DecoratedBox( + decoration: BoxDecoration( + color: MeshPalette.bg1.withValues(alpha: 0.90), + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: MeshPalette.line2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(MeshRadii.md), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: context.l10n.map_zoomIn, + onPressed: () => _zoomMapBy(1), + ), + IconButton( + icon: const Icon(Icons.remove), + tooltip: context.l10n.map_zoomOut, + onPressed: () => _zoomMapBy(-1), + ), + IconButton( + icon: const Icon(Icons.my_location), + tooltip: context.l10n.map_centerMap, + onPressed: _resetMapView, + ), + ], + ), ), ), ); @@ -281,6 +291,7 @@ class _MapCacheScreenState extends State { final tileCache = context.read(); final selectedBounds = _selectedBounds; final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final progressValue = _estimatedTiles == 0 ? 0.0 @@ -318,13 +329,7 @@ class _MapCacheScreenState extends State { ), ), children: [ - TileLayer( - urlTemplate: kMapTileUrlTemplate, - tileProvider: tileCache.tileProvider, - userAgentPackageName: - MapTileCacheService.userAgentPackageName, - maxZoom: 19, - ), + ThemedMapTileLayer(tileCache: tileCache), if (selectedBounds != null) PolygonLayer( polygons: [ @@ -342,14 +347,25 @@ class _MapCacheScreenState extends State { Positioned( top: 12, right: 12, - child: Card( + child: DecoratedBox( + decoration: BoxDecoration( + color: MeshPalette.bg1.withValues(alpha: 0.93), + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: MeshPalette.line2), + ), child: Padding( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), child: Text( selectedBounds == null ? l10n.mapCache_noAreaSelected : _formatBounds(selectedBounds, l10n), - style: const TextStyle(fontSize: 12), + style: MeshTheme.mono( + fontSize: 11, + color: MeshPalette.ink2, + ), ), ), ), @@ -359,111 +375,133 @@ class _MapCacheScreenState extends State { ), SafeArea( top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.mapCache_cacheArea, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + child: DecoratedBox( + decoration: BoxDecoration( + color: scheme.surfaceContainerLow, + border: Border(top: BorderSide(color: scheme.outlineVariant)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SectionHeader( + l10n.mapCache_cacheArea, + padding: const EdgeInsets.fromLTRB(0, 12, 0, 8), ), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - icon: const Icon(Icons.crop_free), - label: Text(l10n.mapCache_useCurrentView), - onPressed: _isDownloading ? null : _setBoundsFromView, + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.crop_free), + label: Text(l10n.mapCache_useCurrentView), + onPressed: _isDownloading + ? null + : _setBoundsFromView, + ), ), - ), - const SizedBox(width: 12), - TextButton( - onPressed: _isDownloading || selectedBounds == null - ? null - : _clearBounds, - child: Text(l10n.common_clear), - ), - ], - ), - const SizedBox(height: 12), - Text( - l10n.mapCache_zoomRange, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - RangeSlider( - values: RangeValues( - _minZoom.toDouble(), - _maxZoom.toDouble(), - ), - min: 3, - max: 18, - divisions: 15, - labels: RangeLabels('$_minZoom', '$_maxZoom'), - onChanged: _isDownloading - ? null - : (values) { - setState(() { - _minZoom = values.start.round(); - _maxZoom = values.end.round(); - }); - }, - onChangeEnd: _isDownloading - ? null - : (_) { - _saveZoomRange(); - }, - ), - Text(l10n.mapCache_estimatedTiles(_estimatedTiles)), - if (_isDownloading) ...[ - const SizedBox(height: 8), - LinearProgressIndicator(value: progressValue), - const SizedBox(height: 4), - Text( - l10n.mapCache_downloadedTiles( - _completedTiles, - _estimatedTiles, - ), - ), - ], - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - icon: const Icon(Icons.download), - label: Text(l10n.mapCache_downloadTilesButton), + const SizedBox(width: 12), + TextButton( onPressed: _isDownloading || selectedBounds == null ? null - : _startDownload, + : _clearBounds, + child: Text(l10n.common_clear), ), + ], + ), + const SizedBox(height: 12), + SectionHeader( + l10n.mapCache_zoomRange, + padding: const EdgeInsets.fromLTRB(0, 8, 0, 0), + ), + RangeSlider( + values: RangeValues( + _minZoom.toDouble(), + _maxZoom.toDouble(), ), - const SizedBox(width: 12), - OutlinedButton( - onPressed: _isDownloading ? null : _clearCache, - child: Text(l10n.mapCache_clearCacheButton), - ), - ], - ), - if (_failedTiles > 0 && !_isDownloading) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - l10n.mapCache_failedDownloads(_failedTiles), - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), + min: 3, + max: 18, + divisions: 15, + labels: RangeLabels('$_minZoom', '$_maxZoom'), + onChanged: _isDownloading + ? null + : (values) { + setState(() { + _minZoom = values.start.round(); + _maxZoom = values.end.round(); + }); + }, + onChangeEnd: _isDownloading + ? null + : (_) { + _saveZoomRange(); + }, + ), + Text( + l10n.mapCache_estimatedTiles(_estimatedTiles), + style: MeshTheme.mono( + fontSize: 12, + color: scheme.onSurfaceVariant, ), ), - ], + if (_isDownloading) ...[ + const SizedBox(height: 8), + LinearProgressIndicator( + value: progressValue, + color: MeshPalette.blue, + backgroundColor: scheme.surfaceContainerHighest, + ), + const SizedBox(height: 4), + Text( + l10n.mapCache_downloadedTiles( + _completedTiles, + _estimatedTiles, + ), + style: MeshTheme.mono( + fontSize: 12, + color: scheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.download), + label: Text(l10n.mapCache_downloadTilesButton), + onPressed: _isDownloading || selectedBounds == null + ? null + : _startDownload, + ), + ), + const SizedBox(width: 12), + OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: MeshPalette.alert, + side: const BorderSide( + color: MeshPalette.alertLine, + ), + ), + onPressed: _isDownloading ? null : _clearCache, + child: Text(l10n.mapCache_clearCacheButton), + ), + ], + ), + if (_failedTiles > 0 && !_isDownloading) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + l10n.mapCache_failedDownloads(_failedTiles), + style: MeshTheme.mono( + fontSize: 12, + color: MeshPalette.alert, + ), + ), + ), + ], + ), ), ), ), diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 3e4193c0..f14a1820 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; @@ -21,13 +22,17 @@ import '../services/path_history_service.dart'; import '../services/map_marker_service.dart'; import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; +import '../utils/battery_utils.dart'; import '../utils/route_transitions.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/sync_progress_overlay.dart'; +import '../widgets/themed_map_tile_layer.dart'; import '../icons/los_icon.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; import 'contacts_screen.dart'; +import '../theme/mesh_theme.dart'; +import '../widgets/mesh_ui.dart'; import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; import '../helpers/snack_bar_builder.dart'; @@ -58,6 +63,11 @@ class MapScreen extends StatefulWidget { class _MapScreenState extends State { // Zoom level at which node labels start to appear static const double _labelZoomThreshold = 14.0; + // Below this zoom, nearby nodes collapse into clusters. + static const double _clusterOffZoom = 12.5; + // Guessed (estimated) locations only render at closer zooms to avoid a + // carpet of approximate markers at city-wide scale. + static const double _guessedZoomThreshold = 12.0; static const double _mapMinZoom = 2.0; static const double _mapMaxZoom = 18.0; @@ -73,11 +83,51 @@ class _MapScreenState extends State { final List _pathTraceContacts = []; final List _points = []; final List _polylines = []; - bool _legendExpanded = false; + bool _statsExpanded = false; bool _showNodeLabels = true; + double _zoom = 10.0; + String? _selectedKey; + LatLng? _selectedGuessPos; + _Freshness _freshness = _Freshness.all; + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + String _searchQuery = ''; List<_GuessedLocation> _cachedGuessedLocations = []; String _guessedLocationsCacheKey = ''; + @override + void dispose() { + _searchController.dispose(); + _searchFocus.dispose(); + _mapController.dispose(); + super.dispose(); + } + + _NodeAge _ageOf(Contact contact) { + final d = DateTime.now().difference(contact.lastSeen); + if (d.inMinutes <= 60) return _NodeAge.online; + if (d.inHours <= 24) return _NodeAge.recent; + return _NodeAge.stale; + } + + void _selectNode(Contact contact, {LatLng? guessedPosition}) { + HapticFeedback.selectionClick(); + setState(() { + _selectedKey = contact.publicKeyHex; + _selectedGuessPos = guessedPosition; + _searchQuery = ''; + _searchController.clear(); + _searchFocus.unfocus(); + }); + } + + void _clearSelection() { + setState(() { + _selectedKey = null; + _selectedGuessPos = null; + }); + } + @override void initState() { super.initState(); @@ -166,38 +216,67 @@ class _MapScreenState extends State { _mapController.move(camera.center, nextZoom); } - Widget _buildDesktopMapControls( + Widget _buildControlRail( BuildContext context, { required LatLng center, required double zoom, - required bool hasPathSelector, + required MeshCoreConnector connector, }) { + final hasSelf = + connector.selfLatitude != null && connector.selfLongitude != null; return Positioned( - left: 16, - top: hasPathSelector ? null : 16, - bottom: hasPathSelector ? 16 : null, - child: Card( - elevation: 4, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.add), - tooltip: context.l10n.map_zoomIn, - onPressed: () => _zoomMapBy(1), - ), - IconButton( - icon: const Icon(Icons.remove), - tooltip: context.l10n.map_zoomOut, - onPressed: () => _zoomMapBy(-1), - ), - IconButton( - icon: const Icon(Icons.my_location), - tooltip: context.l10n.map_centerMap, - onPressed: () => _mapController.move(center, zoom), + left: 12, + bottom: 96, + child: DecoratedBox( + decoration: BoxDecoration( + color: MapPalette.panelDark, + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: MapPalette.border), + boxShadow: const [ + BoxShadow( + color: MapPalette.markerShadow, + blurRadius: 8, + offset: Offset(0, 3), ), ], ), + child: ClipRRect( + borderRadius: BorderRadius.circular(MeshRadii.md), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + color: MapPalette.textPrimary, + icon: const Icon(Icons.add), + visualDensity: VisualDensity.standard, + tooltip: context.l10n.map_zoomIn, + onPressed: () => _zoomMapBy(1), + ), + IconButton( + color: MapPalette.textPrimary, + icon: const Icon(Icons.remove), + tooltip: context.l10n.map_zoomOut, + onPressed: () => _zoomMapBy(-1), + ), + IconButton( + color: MapPalette.textPrimary, + icon: const Icon(Icons.crop_free), + tooltip: context.l10n.map_centerMap, + onPressed: () => _mapController.move(center, zoom), + ), + if (hasSelf) + IconButton( + color: MapPalette.selected, + icon: const Icon(Icons.my_location), + tooltip: context.l10n.map_setAsMyLocation, + onPressed: () => _mapController.move( + LatLng(connector.selfLatitude!, connector.selfLongitude!), + max(_zoom, 14), + ), + ), + ], + ), + ), ), ); } @@ -235,16 +314,27 @@ class _MapScreenState extends State { return hoursSinceLastSeen <= settings.mapTimeFilterHours; }).toList(); + // Quick activity filter (search bar chips) + final filteredByFreshness = switch (_freshness) { + _Freshness.all => filteredByTime, + _Freshness.online => + filteredByTime.where((c) => _ageOf(c) == _NodeAge.online).toList(), + _Freshness.recent => + filteredByTime.where((c) => _ageOf(c) != _NodeAge.stale).toList(), + _Freshness.stale => + filteredByTime.where((c) => _ageOf(c) == _NodeAge.stale).toList(), + }; + // Filter by key prefix final keyPrefix = settings.mapKeyPrefix.trim(); final filteredByKeyPrefix = (settings.mapKeyPrefixEnabled && keyPrefix.isNotEmpty) - ? filteredByTime.where((c) { + ? filteredByFreshness.where((c) { return c.publicKeyHex.toLowerCase().startsWith( keyPrefix.toLowerCase(), ); }).toList() - : filteredByTime; + : filteredByFreshness; // Filter by location final contactsWithLocation = filteredByKeyPrefix.where((c) { @@ -292,7 +382,7 @@ class _MapScreenState extends State { Polyline( points: _points, strokeWidth: 4, - color: Colors.blueAccent, + color: MapPalette.selected, ), ] : [], @@ -308,8 +398,10 @@ class _MapScreenState extends State { Polyline( points: points, color: marker.isChannel - ? (marker.isPublicChannel ? Colors.orange : Colors.purple) - : Colors.blue, + ? (marker.isPublicChannel + ? MapPalette.cluster + : MapPalette.router) + : MapPalette.shared, strokeWidth: 3, ), ); @@ -397,6 +489,7 @@ class _MapScreenState extends State { if (!_hasInitializedMap && _removedMarkersLoaded) { _hasInitializedMap = true; _showNodeLabels = initialZoom >= _labelZoomThreshold; + _zoom = initialZoom; if (hasMapContent) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -408,10 +501,34 @@ class _MapScreenState extends State { final allowBack = !connector.isConnected; + final visibleContacts = _filterContactsBySettings( + contactsWithLocation, + settings, + ); + Contact? selectedContact; + if (_selectedKey != null) { + for (final c in allContacts) { + if (c.publicKeyHex == _selectedKey) { + selectedContact = c; + break; + } + } + } + final locatedTotal = allContacts.where((c) => c.hasLocation).length; + final hiddenCount = max(0, locatedTotal - visibleContacts.length); + final onlineCount = visibleContacts + .where((c) => _ageOf(c) == _NodeAge.online) + .length; + final repeaterCount = visibleContacts + .where((c) => c.type == advTypeRepeater) + .length; + return PopScope( canPop: allowBack, child: Scaffold( appBar: AppBar( + backgroundColor: MapPalette.panelDark, + foregroundColor: MapPalette.textPrimary, title: AppBarTitle(context.l10n.map_title), centerTitle: true, automaticallyImplyLeading: false, @@ -457,7 +574,7 @@ class _MapScreenState extends State { connector.selfLatitude!, connector.selfLongitude!, ), - color: Colors.teal, + color: MapPalette.selected, icon: Icons.person_pin_circle, ), ); @@ -550,6 +667,17 @@ class _MapScreenState extends State { defaultLabel: context.l10n.map_pointOfInterest, flags: 'poi', ); + return; + } + // Tapping empty map dismisses selection + search. + if (_selectedKey != null || _searchQuery.isNotEmpty) { + setState(() { + _selectedKey = null; + _selectedGuessPos = null; + _searchQuery = ''; + _searchController.clear(); + _searchFocus.unfocus(); + }); } }, onLongPress: (_, latLng) { @@ -573,22 +701,21 @@ class _MapScreenState extends State { ); }, onPositionChanged: (camera, hasGesture) { + // Track zoom in half-step buckets so cluster/marker + // detail levels update without rebuilding every frame. + final bucket = (camera.zoom * 2).roundToDouble() / 2; final shouldShow = camera.zoom >= _labelZoomThreshold; - if (shouldShow != _showNodeLabels && mounted) { + if ((bucket != _zoom || shouldShow != _showNodeLabels) && + mounted) { setState(() { + _zoom = bucket; _showNodeLabels = shouldShow; }); } }, ), children: [ - TileLayer( - urlTemplate: kMapTileUrlTemplate, - tileProvider: tileCache.tileProvider, - userAgentPackageName: - MapTileCacheService.userAgentPackageName, - maxZoom: 19, - ), + ThemedMapTileLayer(tileCache: tileCache), if (_polylines.isNotEmpty && _isBuildingPathTrace) PolylineLayer(polylines: _polylines), if (sharedMarkerPolylines.isNotEmpty) @@ -598,25 +725,45 @@ class _MapScreenState extends State { if (highlightPosition != null) Marker( point: highlightPosition, - width: 40, - height: 40, + width: 44, + height: 44, child: IgnorePointer( - child: Icon( - Icons.location_on_outlined, - color: Colors.red[600], - size: 34, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: MapPalette.batteryLow, + border: Border.all( + color: MapPalette.markerOutline, + width: 3, + ), + boxShadow: const [ + BoxShadow( + color: MapPalette.markerShadow, + blurRadius: 8, + offset: Offset(0, 3), + ), + ], + ), + child: const Icon( + Icons.location_on, + color: Colors.white, + size: 25, + ), ), ), ), - if (!settings.mapShowOverlaps) + if (!settings.mapShowOverlaps && + (_zoom >= _guessedZoomThreshold || + _isBuildingPathTrace)) ..._buildGuessedMarker( guessedLocations, showLabels: _showNodeLabels, ), - ..._buildMarkers( - contactsWithLocation, + ..._buildNodeMarkers( + visibleContacts, settings, showLabels: _showNodeLabels, + selectedContact: selectedContact, ), ...sharedMarkers.map(_buildSharedMarker), if (connector.selfLatitude != null && @@ -631,29 +778,28 @@ class _MapScreenState extends State { child: IgnorePointer( ignoring: true, child: Container( - padding: const EdgeInsets.all(4), + width: 36, + height: 36, decoration: BoxDecoration( - color: Colors.teal, shape: BoxShape.circle, + color: MapPalette.panelDark, border: Border.all( - color: Colors.white, - width: 2, + color: MapPalette.markerOutline, + width: 2.5, ), boxShadow: [ - BoxShadow( - color: Colors.black.withValues( - alpha: 0.3, - ), - blurRadius: 4, - offset: const Offset(0, 2), + const BoxShadow( + color: MapPalette.markerShadow, + blurRadius: 8, + offset: Offset(0, 2), ), ], ), alignment: Alignment.center, child: const Icon( Icons.person_pin_circle, - color: Colors.white, - size: 20, + color: MapPalette.selected, + size: 22, ), ), ), @@ -672,22 +818,34 @@ class _MapScreenState extends State { ), ], ), - if (!_isBuildingPathTrace) - _buildLegend( - contacts, - contactsWithLocation, - settings, - sharedMarkers.length, - guessedLocations.length, - ), - if (isDesktop) - _buildDesktopMapControls( + if (selectedContact == null) + _buildControlRail( context, center: center, zoom: initialZoom, - hasPathSelector: _isBuildingPathTrace, + connector: connector, + ), + if (!_isBuildingPathTrace) + _buildTopOverlay( + context, + connector: connector, + settingsService: settingsService, + allContacts: allContacts, + guessedLocations: guessedLocations, + visibleCount: + visibleContacts.length + + ((settings.mapShowGuessedLocations && + _zoom >= _guessedZoomThreshold) + ? guessedLocations.length + : 0), + onlineCount: onlineCount, + repeaterCount: repeaterCount, + hiddenCount: hiddenCount, + pinCount: sharedMarkers.length, ), if (_isBuildingPathTrace) _buildPathTraceOverlay(), + if (selectedContact != null && !_isBuildingPathTrace) + _buildSelectedNodeCard(context, selectedContact, connector), ], ), bottomNavigationBar: SafeArea( @@ -698,13 +856,17 @@ class _MapScreenState extends State { _handleQuickSwitch(index, context), contactsUnreadCount: connector.getTotalContactsUnreadCount(), channelsUnreadCount: connector.getTotalChannelsUnreadCount(), + highContrast: true, ), ), - floatingActionButton: FloatingActionButton( - onPressed: () => _showFilterDialog(context, settingsService), - tooltip: context.l10n.map_filterNodes, - child: const Icon(Icons.filter_list), - ), + floatingActionButton: + (selectedContact == null && !_isBuildingPathTrace) + ? FloatingActionButton( + onPressed: () => _showFilterSheet(context, settingsService), + tooltip: context.l10n.map_filterNodes, + child: const Icon(Icons.filter_list), + ) + : null, ), ); }, @@ -932,32 +1094,31 @@ class _MapScreenState extends State { : null, onTap: () => _isBuildingPathTrace ? _addToPath(context, guess.contact, position: guess.position) - : _showNodeInfo( - context, - guess.contact, - guessedPosition: guess.position, - ), + : _selectNode(guess.contact, guessedPosition: guess.position), child: Center( child: Container( - padding: const EdgeInsets.all(4), + width: 36, + height: 36, decoration: BoxDecoration( - color: color.withValues( - alpha: guess.highConfidence ? 0.55 : 0.30, - ), shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ + color: MapPalette.panelDark, + border: Border.all( + color: guess.highConfidence ? color : MapPalette.textMuted, + width: guess.highConfidence ? 2.5 : 2, + ), + boxShadow: const [ BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), + color: MapPalette.markerShadow, + blurRadius: 7, + offset: Offset(0, 2), ), ], ), - child: const Icon( + alignment: Alignment.center, + child: Icon( Icons.not_listed_location, - color: Colors.white, - size: 20, + color: MapPalette.textPrimary, + size: 19, ), ), ), @@ -1040,57 +1201,24 @@ class _MapScreenState extends State { return filtered; } - List _buildMarkers( + List _buildNodeMarkers( List contacts, settings, { required bool showLabels, + Contact? selectedContact, }) { final markers = []; - final filteredContacts = _filterContactsBySettings(contacts, settings); - for (final contact in filteredContacts) { - final marker = Marker( - point: LatLng(contact.latitude!, contact.longitude!), - width: 48, - height: 48, - child: GestureDetector( - onLongPress: () => - _isBuildingPathTrace ? _showNodeInfo(context, contact) : null, - onTap: () => _isBuildingPathTrace - ? _addToPath(context, contact) - : _showNodeInfo(context, contact), - child: Center( - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: settings.mapShowOverlaps && !_isBuildingPathTrace - ? Colors.red - : _getNodeColor(contact.type), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Icon( - _getNodeIcon(contact.type), - color: Colors.white, - size: 20, - ), - ), - ), - ), - ); + final overlapsMode = settings.mapShowOverlaps && !_isBuildingPathTrace; + final selectedKey = selectedContact?.publicKeyHex; + final items = contacts.where((c) => c.publicKeyHex != selectedKey).toList(); - markers.add(marker); + void addNode(Contact contact, {bool dot = false}) { + markers.add(_nodeMarker(contact, overlapsMode: overlapsMode, dot: dot)); if (showLabels) { markers.add( _buildNodeLabelMarker( point: LatLng(contact.latitude!, contact.longitude!), - label: settings.mapShowOverlaps && !_isBuildingPathTrace + label: overlapsMode ? "${contact.publicKeyHex.substring(0, 2)}:${contact.name}" : contact.name, ), @@ -1098,9 +1226,196 @@ class _MapScreenState extends State { } } + if (_zoom >= _clusterOffZoom || overlapsMode || _isBuildingPathTrace) { + for (final contact in items) { + addNode(contact); + } + } else { + // Grid clustering: bucket markers into ~64px screen cells at the + // current zoom; cells with 2+ nodes render as a numbered cluster. + final cellDeg = 360.0 / (256.0 * pow(2.0, _zoom)) * 64.0; + final cells = >{}; + for (final contact in items) { + final key = + '${(contact.latitude! / cellDeg).floor()}:${(contact.longitude! / cellDeg).floor()}'; + (cells[key] ??= []).add(contact); + } + for (final cell in cells.values) { + if (cell.length == 1) { + addNode(cell.first, dot: true); + } else { + markers.add(_clusterMarker(cell)); + } + } + } + + // Selected node always renders individually on top, even when its + // neighbors are clustered or it is filtered out. + if (selectedContact != null && selectedContact.hasLocation) { + markers.add( + _nodeMarker( + selectedContact, + overlapsMode: overlapsMode, + selected: true, + ), + ); + markers.add( + _buildNodeLabelMarker( + point: LatLng(selectedContact.latitude!, selectedContact.longitude!), + label: selectedContact.name, + ), + ); + } + return markers; } + Marker _nodeMarker( + Contact contact, { + bool overlapsMode = false, + bool dot = false, + bool selected = false, + }) { + final age = _ageOf(contact); + final baseColor = overlapsMode + ? MapPalette.batteryLow + : _markerColor(contact); + final stale = age == _NodeAge.stale; + final online = age == _NodeAge.online; + final batteryLow = _isBatteryLow(contact); + final size = selected ? 46.0 : (dot ? 22.0 : 40.0); + return Marker( + point: LatLng(contact.latitude!, contact.longitude!), + width: size, + height: size, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: () => + _isBuildingPathTrace ? _showNodeInfo(context, contact) : null, + onTap: () => _isBuildingPathTrace + ? _addToPath(context, contact) + : _selectNode(contact), + child: Center( + child: dot && !selected + ? Container( + width: 15, + height: 15, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: baseColor, + border: Border.all( + color: MapPalette.markerOutline, + width: 2, + ), + boxShadow: const [ + BoxShadow( + color: MapPalette.markerShadow, + blurRadius: 6, + offset: Offset(0, 2), + ), + ], + ), + ) + : _buildNodeMarkerWidget( + color: baseColor, + icon: _getNodeIcon(contact.type), + selected: selected, + stale: stale, + online: online, + batteryLow: batteryLow, + ), + ), + ), + ); + } + + Marker _clusterMarker(List members) { + final count = members.length; + double lat = 0, lon = 0; + var online = 0; + for (final m in members) { + lat += m.latitude!; + lon += m.longitude!; + if (_ageOf(m) == _NodeAge.online) online++; + } + final center = LatLng(lat / count, lon / count); + final size = count >= 50 + ? 54.0 + : count >= 16 + ? 50.0 + : count >= 6 + ? 46.0 + : 42.0; + return Marker( + point: center, + width: size, + height: size, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _zoomToCluster(members), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: MapPalette.cluster, + border: Border.all(color: MapPalette.markerOutline, width: 3), + boxShadow: const [ + BoxShadow( + color: MapPalette.markerShadow, + blurRadius: 8, + offset: Offset(0, 3), + ), + ], + ), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '$count', + style: MeshTheme.mono( + fontSize: count >= 100 ? 11.5 : 13.5, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + if (online > 0) + Container( + width: 7, + height: 7, + margin: const EdgeInsets.only(top: 1), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: MapPalette.online, + border: Border.all(color: Colors.white, width: 1), + ), + ), + ], + ), + ), + ), + ); + } + + void _zoomToCluster(List members) { + HapticFeedback.selectionClick(); + var minLat = double.infinity, maxLat = -double.infinity; + var minLon = double.infinity, maxLon = -double.infinity; + for (final m in members) { + minLat = min(minLat, m.latitude!); + maxLat = max(maxLat, m.latitude!); + minLon = min(minLon, m.longitude!); + maxLon = max(maxLon, m.longitude!); + } + _mapController.fitCamera( + CameraFit.bounds( + bounds: LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon)), + padding: const EdgeInsets.all(72), + maxZoom: 16, + ), + ); + } + Marker _buildNodeLabelMarker({required LatLng point, required String label}) { return Marker( point: point, @@ -1115,18 +1430,26 @@ class _MapScreenState extends State { child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), + color: MapPalette.panelDark, + borderRadius: BorderRadius.circular(MeshRadii.xs), + border: Border.all(color: MapPalette.border), + boxShadow: const [ + BoxShadow( + color: MapPalette.markerShadow, + blurRadius: 4, + offset: Offset(0, 1), + ), + ], ), alignment: Alignment.center, child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w500, + style: MeshTheme.mono( + fontSize: 10, + fontWeight: FontWeight.w700, + color: MapPalette.textPrimary, ), ), ), @@ -1139,18 +1462,44 @@ class _MapScreenState extends State { Color _getNodeColor(int type) { switch (type) { case advTypeChat: - return Colors.blue; + return MapPalette.selected; case advTypeRepeater: - return Colors.green; + return MapPalette.repeater; case advTypeRoom: - return Colors.purple; + return MapPalette.router; case advTypeSensor: - return Colors.orange; + return MapPalette.sensor; default: - return Colors.grey; + return MapPalette.offline; } } + Color _markerColor(Contact contact) { + switch (contact.type) { + case advTypeRepeater: + return MapPalette.repeater; + case advTypeRoom: + return MapPalette.router; + case advTypeSensor: + return MapPalette.sensor; + default: + return _ageColor(_ageOf(contact)); + } + } + + bool _isBatteryLow(Contact contact) { + if (contact.type != advTypeRepeater) return false; + final connector = context.read(); + final millivolts = connector.getRepeaterBatteryMillivolts( + contact.publicKeyHex, + ); + if (millivolts == null) return false; + final chemistry = context + .read() + .batteryChemistryForRepeater(contact.publicKeyHex); + return estimateBatteryPercentFromMillivolts(millivolts, chemistry) <= 20; + } + IconData _getNodeIcon(int type) { switch (type) { case advTypeChat: @@ -1166,211 +1515,862 @@ class _MapScreenState extends State { } } - Widget _buildLegend( - List contacts, - List contactsWithLocation, - settings, - int markerCount, - int guessedCount, - ) { - final filteredContacts = _filterContactsBySettings( - contacts, - settings, - noLocations: false, - ); - final filteredContactsAll = _filterContactsBySettings( - contacts, - settings, - noLocations: true, - ); - - final nodeCount = filteredContacts.length; - final nodeCountAll = filteredContactsAll.length; - - return Positioned( - top: 16, - right: 16, - child: Card( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - setState(() { - _legendExpanded = !_legendExpanded; - }); - }, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.map_nodesCount( - nodeCount + - (settings.mapShowGuessedLocations - ? guessedCount - : 0), - ), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - Row( - children: [ - Icon( - Icons.location_on, - size: 16, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - Text( - ": $nodeCount", - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ], - ), - Row( - children: [ - Icon( - Icons.wrong_location, - size: 16, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - Text( - ": ${nodeCountAll - nodeCount}", - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ], - ), - Row( - children: [ - Icon( - Icons.add_outlined, - size: 16, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - Text( - ": $nodeCountAll", - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ], - ), - Text( - context.l10n.map_pinsCount(markerCount), - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - const SizedBox(width: 8), - AnimatedRotation( - turns: _legendExpanded ? 0.5 : 0, - duration: const Duration(milliseconds: 200), - child: const Icon(Icons.expand_more, size: 20), - ), - ], - ), - ), + Widget _buildNodeMarkerWidget({ + required Color color, + required IconData icon, + bool selected = false, + bool stale = false, + bool online = false, + bool batteryLow = false, + }) { + final statusColor = batteryLow + ? MapPalette.batteryLow + : online + ? MapPalette.online + : stale + ? MapPalette.offline + : MapPalette.stale; + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Container( + width: selected ? 44 : 36, + height: selected ? 44 : 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? MapPalette.selected : color, + border: Border.all( + color: MapPalette.markerOutline, + width: selected ? 3 : 2.5, ), - AnimatedCrossFade( - firstChild: const SizedBox.shrink(), - secondChild: Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 6), - _buildLegendItem( - Icons.person, - context.l10n.map_chat, - Colors.blue, - ), - _buildLegendItem( - Icons.router, - context.l10n.map_repeater, - Colors.green, - ), - _buildLegendItem( - Icons.meeting_room, - context.l10n.map_room, - Colors.purple, - ), - _buildLegendItem( - Icons.sensors, - context.l10n.map_sensor, - Colors.orange, - ), - _buildLegendItem( - Icons.flag, - context.l10n.map_pinDm, - Colors.blue, - ), - _buildLegendItem( - Icons.flag, - context.l10n.map_pinPrivate, - Colors.purple, - ), - _buildLegendItem( - Icons.flag, - context.l10n.map_pinPublic, - Colors.orange, - ), - if (settings.mapShowGuessedLocations && guessedCount > 0) - _buildLegendItem( - Icons.not_listed_location, - context.l10n.map_guessedLocation, - Colors.grey, - ), - ], - ), + boxShadow: [ + const BoxShadow( + color: MapPalette.markerShadow, + blurRadius: 8, + offset: Offset(0, 3), ), - crossFadeState: _legendExpanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), - ), - ], + if (selected) + BoxShadow( + color: MapPalette.selected.withValues(alpha: 0.75), + blurRadius: 14, + spreadRadius: 3, + ), + ], + ), + alignment: Alignment.center, + child: Icon(icon, color: Colors.white, size: selected ? 22 : 19), ), - ), + Positioned( + right: selected ? -1 : -2, + bottom: selected ? 0 : -2, + child: Container( + width: batteryLow ? 16 : (selected ? 13 : 12), + height: batteryLow ? 16 : (selected ? 13 : 12), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: statusColor, + border: Border.all(color: MapPalette.panelDark, width: 2), + ), + alignment: Alignment.center, + child: batteryLow + ? const Icon(Icons.battery_alert, size: 10, color: Colors.white) + : null, + ), + ), + ], ); } Widget _buildLegendItem(IconData icon, String label, Color color) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 1.0), + padding: const EdgeInsets.symmetric(vertical: 1.5), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 16, color: color), + Icon(icon, size: 15, color: color), const SizedBox(width: 8), - Text(label, style: const TextStyle(fontSize: 12)), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: MapPalette.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), ], ), ); } + Color _ageColor(_NodeAge age) { + switch (age) { + case _NodeAge.online: + return MapPalette.online; + case _NodeAge.recent: + return MapPalette.stale; + case _NodeAge.stale: + return MapPalette.textMuted; + } + } + + String _ageLabel(_NodeAge age) { + switch (age) { + case _NodeAge.online: + return context.l10n.map_online; + case _NodeAge.recent: + return context.l10n.map_recent; + case _NodeAge.stale: + return context.l10n.map_stale; + } + } + + Widget _buildTopOverlay( + BuildContext context, { + required MeshCoreConnector connector, + required AppSettingsService settingsService, + required List allContacts, + required List<_GuessedLocation> guessedLocations, + required int visibleCount, + required int onlineCount, + required int repeaterCount, + required int hiddenCount, + required int pinCount, + }) { + final settings = settingsService.settings; + final hasQuery = _searchQuery.trim().isNotEmpty; + return Positioned( + top: 8, + left: 12, + right: 12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Material( + color: MapPalette.panelDark, + shape: StadiumBorder( + side: const BorderSide(color: MapPalette.border), + ), + clipBehavior: Clip.antiAlias, + child: TextField( + controller: _searchController, + focusNode: _searchFocus, + decoration: InputDecoration( + hintText: context.l10n.map_searchHint, + hintStyle: const TextStyle( + color: MapPalette.textSecondary, + ), + prefixIcon: const Icon( + Icons.search, + size: 20, + color: MapPalette.textPrimary, + ), + suffixIcon: hasQuery + ? IconButton( + color: MapPalette.textPrimary, + icon: const Icon(Icons.close, size: 18), + onPressed: () { + setState(() { + _searchQuery = ''; + _searchController.clear(); + }); + }, + ) + : null, + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 12, + ), + ), + style: const TextStyle( + fontSize: 14, + color: MapPalette.textPrimary, + fontWeight: FontWeight.w600, + ), + cursorColor: MapPalette.selected, + onChanged: (value) { + setState(() => _searchQuery = value); + }, + ), + ), + ), + const SizedBox(width: 8), + Material( + color: MapPalette.panelDark, + shape: StadiumBorder( + side: const BorderSide(color: MapPalette.border), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => setState(() => _statsExpanded = !_statsExpanded), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 11, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.hub, + size: 15, + color: MapPalette.selected, + ), + const SizedBox(width: 6), + Text( + '$visibleCount', + style: MeshTheme.mono( + fontSize: 13, + fontWeight: FontWeight.w700, + color: MapPalette.textPrimary, + ), + ), + const SizedBox(width: 2), + AnimatedRotation( + turns: _statsExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: const Icon( + Icons.expand_more, + size: 16, + color: MapPalette.textPrimary, + ), + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 6), + LayoutBuilder( + builder: (context, constraints) { + final chips = [ + _mapChip( + label: context.l10n.time_allTime, + selected: _freshness == _Freshness.all, + onTap: () => setState(() => _freshness = _Freshness.all), + ), + _mapChip( + label: context.l10n.map_online, + selected: _freshness == _Freshness.online, + color: MapPalette.online, + onTap: () => setState(() => _freshness = _Freshness.online), + ), + _mapChip( + label: context.l10n.map_recent, + selected: _freshness == _Freshness.recent, + color: MapPalette.stale, + onTap: () => setState(() => _freshness = _Freshness.recent), + ), + _mapChip( + label: context.l10n.map_stale, + selected: _freshness == _Freshness.stale, + color: MapPalette.offline, + onTap: () => setState(() => _freshness = _Freshness.stale), + ), + _mapChip( + label: context.l10n.map_repeaters, + selected: settings.mapShowRepeaters, + color: MapPalette.repeater, + onTap: () => settingsService.setMapShowRepeaters( + !settings.mapShowRepeaters, + ), + ), + _mapChip( + label: context.l10n.map_chatNodes, + selected: settings.mapShowChatNodes, + color: MapPalette.selected, + onTap: () => settingsService.setMapShowChatNodes( + !settings.mapShowChatNodes, + ), + ), + ]; + + if (constraints.maxWidth < 600) { + return Wrap(runSpacing: 6, children: chips); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: chips), + ); + }, + ), + if (hasQuery) + _buildSearchResults(context, allContacts, guessedLocations) + else if (_statsExpanded) + Align( + alignment: Alignment.centerRight, + child: _buildStatsCard( + context, + settings: settings, + visibleCount: visibleCount, + onlineCount: onlineCount, + repeaterCount: repeaterCount, + hiddenCount: hiddenCount, + pinCount: pinCount, + guessedCount: guessedLocations.length, + ), + ), + ], + ), + ); + } + + Widget _mapChip({ + required String label, + required bool selected, + required VoidCallback onTap, + Color? color, + }) { + final accent = color ?? MapPalette.selected; + return Padding( + padding: const EdgeInsets.only(right: 6), + child: Material( + color: selected + ? Color.alphaBlend( + accent.withValues(alpha: 0.34), + MapPalette.panelDark, + ) + : MapPalette.panelDark, + shape: StadiumBorder( + side: BorderSide( + color: selected ? accent : MapPalette.border, + width: selected ? 1.5 : 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + HapticFeedback.selectionClick(); + onTap(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (selected) ...[ + const Icon( + Icons.check, + size: 13, + color: MapPalette.textPrimary, + ), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: selected + ? MapPalette.textPrimary + : MapPalette.textSecondary, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildSearchResults( + BuildContext context, + List allContacts, + List<_GuessedLocation> guessedLocations, + ) { + final query = _searchQuery.trim().toLowerCase(); + final matches = + allContacts.where((c) => matchesContactQuery(c, query)).toList() + ..sort((a, b) { + if (a.hasLocation != b.hasLocation) { + return a.hasLocation ? -1 : 1; + } + return b.lastSeen.compareTo(a.lastSeen); + }); + final results = matches.take(8).toList(); + return Container( + margin: const EdgeInsets.only(top: 6), + constraints: const BoxConstraints(maxHeight: 300), + decoration: BoxDecoration( + color: MapPalette.panelDark, + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: MapPalette.border), + boxShadow: const [ + BoxShadow( + color: MapPalette.markerShadow, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: results.isEmpty + ? Padding( + padding: const EdgeInsets.all(16), + child: Text( + context.l10n.map_noResults, + style: const TextStyle( + color: MapPalette.textSecondary, + fontSize: 13, + ), + ), + ) + : ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: results.length, + separatorBuilder: (_, _) => + const Divider(height: 1, color: MapPalette.border), + itemBuilder: (context, index) { + final c = results[index]; + final color = _getNodeColor(c.type); + return InkWell( + onTap: () => _onSearchResultTap(c, guessedLocations), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Row( + children: [ + Icon(_getNodeIcon(c.type), size: 18, color: color), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + c.name, + style: const TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w600, + color: MapPalette.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + Text( + c.publicKeyHex.substring(0, 12), + style: MeshTheme.mono( + fontSize: 10.5, + color: MapPalette.textSecondary, + ), + ), + ], + ), + ), + if (c.hasLocation) + Icon( + Icons.chevron_right, + size: 18, + color: MapPalette.textSecondary, + ) + else + Text( + context.l10n.map_noGps.toUpperCase(), + style: MeshTheme.accentLabel( + color: MapPalette.textMuted, + fontSize: 8.5, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + void _onSearchResultTap( + Contact contact, + List<_GuessedLocation> guessedLocations, + ) { + if (contact.hasLocation) { + _selectNode(contact); + _mapController.move( + LatLng(contact.latitude!, contact.longitude!), + max(_zoom, 14), + ); + return; + } + _GuessedLocation? guess; + for (final g in guessedLocations) { + if (g.contact.publicKeyHex == contact.publicKeyHex) { + guess = g; + break; + } + } + if (guess != null) { + _selectNode(contact, guessedPosition: guess.position); + _mapController.move(guess.position, max(_zoom, 13)); + } else { + setState(() { + _searchQuery = ''; + _searchController.clear(); + _searchFocus.unfocus(); + }); + _showNodeInfo(context, contact); + } + } + + Widget _buildStatsCard( + BuildContext context, { + required dynamic settings, + required int visibleCount, + required int onlineCount, + required int repeaterCount, + required int hiddenCount, + required int pinCount, + required int guessedCount, + }) { + return Container( + margin: const EdgeInsets.only(top: 6), + width: 230, + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + decoration: BoxDecoration( + color: MapPalette.panelDark, + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: MapPalette.border), + boxShadow: const [ + BoxShadow( + color: MapPalette.markerShadow, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _statRow(context.l10n.map_visible, visibleCount, MapPalette.selected), + _statRow(context.l10n.map_online, onlineCount, MapPalette.online), + _statRow( + context.l10n.map_repeaters, + repeaterCount, + MapPalette.repeater, + ), + _statRow(context.l10n.map_hidden, hiddenCount, MapPalette.offline), + _statRow(context.l10n.map_markers, pinCount, MapPalette.shared), + const Divider(height: 16, color: MapPalette.border), + _buildLegendItem( + Icons.person, + context.l10n.map_chat, + MapPalette.selected, + ), + _buildLegendItem( + Icons.router, + context.l10n.map_repeater, + MapPalette.repeater, + ), + _buildLegendItem( + Icons.meeting_room, + context.l10n.map_room, + MapPalette.router, + ), + _buildLegendItem( + Icons.sensors, + context.l10n.map_sensor, + MapPalette.sensor, + ), + _buildLegendItem( + Icons.flag, + context.l10n.map_pinDm, + MapPalette.shared, + ), + if (settings.mapShowGuessedLocations && guessedCount > 0) + _buildLegendItem( + Icons.not_listed_location, + context.l10n.map_guessedLocation, + MapPalette.textMuted, + ), + ], + ), + ); + } + + Widget _statRow(String label, int value, Color color) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: TextStyle(fontSize: 12.5, color: MapPalette.textSecondary), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '$value', + style: MeshTheme.mono( + fontSize: 13, + fontWeight: FontWeight.w700, + color: MapPalette.textPrimary, + ), + ), + ], + ), + ); + } + + Widget _buildSelectedNodeCard( + BuildContext context, + Contact contact, + MeshCoreConnector connector, + ) { + final color = _markerColor(contact); + final age = _ageOf(contact); + final pos = contact.hasLocation + ? LatLng(contact.latitude!, contact.longitude!) + : _selectedGuessPos; + return Positioned( + left: 12, + right: 12, + bottom: 12, + child: TweenAnimationBuilder( + tween: Tween(begin: 1, end: 0), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + builder: (context, t, child) => Transform.translate( + offset: Offset(0, 32 * t), + child: Opacity(opacity: 1 - t, child: child), + ), + child: MeshCard( + margin: EdgeInsets.zero, + padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), + color: MapPalette.panelDark, + borderColor: MapPalette.border, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AvatarCircle( + name: contact.name, + size: 38, + color: color, + icon: _getNodeIcon(contact.type), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + contact.name, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: MapPalette.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (contact.isFavorite) ...[ + const SizedBox(width: 4), + const Icon( + Icons.star, + size: 14, + color: MapPalette.stale, + ), + ], + ], + ), + const SizedBox(height: 3), + Row( + children: [ + StatusChip( + label: _ageLabel(age), + color: _ageColor(age), + fontSize: 9.5, + pulse: age == _NodeAge.online, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + contact.typeLabel(context.l10n), + style: const TextStyle( + fontSize: 11.5, + color: MapPalette.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + if (pos != null) + IconButton( + color: MapPalette.textPrimary, + icon: const Icon(Icons.center_focus_strong, size: 20), + tooltip: context.l10n.map_centerOnNode, + onPressed: () => _mapController.move(pos, max(_zoom, 15)), + ), + IconButton( + color: MapPalette.textPrimary, + icon: const Icon(Icons.close, size: 20), + onPressed: _clearSelection, + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 14, + runSpacing: 4, + children: [ + _miniMeta( + context.l10n.map_lastSeen, + _formatLastSeen(contact.lastSeen), + ), + _miniMeta( + context.l10n.map_path, + contact.pathLabel(context.l10n), + ), + _miniMeta('ID', contact.publicKeyHex.substring(0, 12)), + if (pos != null) + _miniMeta( + context.l10n.map_location, + '${contact.hasLocation ? '' : '~'}${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}', + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + ..._selectedNodeActions(context, contact, connector), + TextButton( + style: TextButton.styleFrom( + foregroundColor: MapPalette.selected, + ), + onPressed: () => _showNodeInfo( + context, + contact, + guessedPosition: contact.hasLocation + ? null + : _selectedGuessPos, + ), + child: Text(context.l10n.map_details), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _miniMeta(String label, String value) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label.toUpperCase(), + style: MeshTheme.accentLabel( + color: MapPalette.textMuted, + fontSize: 8, + ), + ), + const SizedBox(height: 1), + Text( + value, + style: MeshTheme.mono(fontSize: 11.5, color: MapPalette.textPrimary), + ), + ], + ); + } + + List _selectedNodeActions( + BuildContext context, + Contact contact, + MeshCoreConnector connector, + ) { + Widget action(String label, IconData icon, VoidCallback onPressed) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilledButton.icon( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + visualDensity: VisualDensity.compact, + ), + onPressed: onPressed, + icon: Icon(icon, size: 16), + label: Text(label, style: const TextStyle(fontSize: 12.5)), + ), + ); + } + + switch (contact.type) { + case advTypeChat: + return [ + action(context.l10n.contacts_openChat, Icons.chat_bubble_outline, () { + if (!contact.isActive) { + connector.importDiscoveredContact(contact); + } + final unread = connector.getUnreadCountForContactKey( + contact.publicKeyHex, + ); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + ChatScreen(contact: contact, initialUnreadCount: unread), + ), + ); + }), + ]; + case advTypeRepeater: + return [ + action(context.l10n.map_manageRepeater, Icons.cell_tower, () { + if (!contact.isActive) { + connector.importDiscoveredContact(contact); + } + _showRepeaterLogin(context, contact); + }), + ]; + case advTypeRoom: + return [ + action(context.l10n.map_joinRoom, Icons.meeting_room, () { + if (!contact.isActive) { + connector.importDiscoveredContact(contact); + } + _showRoomLogin(context, contact); + }), + ]; + default: + return const []; + } + } + List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) { // Build a _SharedMarker per message (history empty), grouped by dedupe key. // Afterwards pick the latest per key and fill its history from older ones. @@ -1468,8 +2468,8 @@ class _MapScreenState extends State { Marker _buildSharedMarker(_SharedMarker marker) { final markerColor = marker.isChannel - ? (marker.isPublicChannel ? Colors.orange : Colors.purple) - : Colors.blue; + ? (marker.isPublicChannel ? MapPalette.cluster : MapPalette.router) + : MapPalette.shared; return Marker( point: marker.position, width: 60, @@ -1487,20 +2487,22 @@ class _MapScreenState extends State { child: Column( children: [ Container( - padding: const EdgeInsets.all(6), + width: 36, + height: 36, decoration: BoxDecoration( - color: markerColor, shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ + color: markerColor, + border: Border.all(color: MapPalette.markerOutline, width: 2.5), + boxShadow: const [ BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), + color: MapPalette.markerShadow, + blurRadius: 8, + offset: Offset(0, 3), ), ], ), - child: const Icon(Icons.flag, color: Colors.white, size: 20), + alignment: Alignment.center, + child: const Icon(Icons.flag, color: Colors.white, size: 19), ), ], ), @@ -1560,9 +2562,8 @@ class _MapScreenState extends State { LatLng? guessedPosition, }) { final connector = context.read(); - showModalBottomSheet( - context: context, - showDragHandle: true, + showMeshSheet( + context, builder: (sheetContext) { final actions = []; if (contact.type == advTypeChat) { @@ -1619,58 +2620,53 @@ class _MapScreenState extends State { ); } return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BottomSheetHeader( + title: contact.name, + subtitle: contact.typeLabel(context.l10n), + trailing: Icon( + _getNodeIcon(contact.type), + color: _getNodeColor(contact.type), + size: 20, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - _getNodeIcon(contact.type), - color: _getNodeColor(contact.type), + _buildInfoRow( + context.l10n.map_path, + contact.pathLabel(context.l10n), ), - const SizedBox(width: 8), - Expanded(child: SelectableText(contact.name)), + if (contact.hasLocation) + _buildInfoRow( + context.l10n.map_location, + '${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}', + ) + else if (guessedPosition != null) + _buildInfoRow( + context.l10n.map_estLocation, + '~${guessedPosition.latitude.toStringAsFixed(6)}, ${guessedPosition.longitude.toStringAsFixed(6)}', + ), + _buildInfoRow( + context.l10n.map_lastSeen, + _formatLastSeen(contact.lastSeen), + ), + _buildInfoRow( + context.l10n.map_publicKey, + contact.publicKeyHex, + ), + const SizedBox(height: 16), + ...actions, ], ), - const SizedBox(height: 8), - _buildInfoRow( - context.l10n.map_type, - contact.typeLabel(context.l10n), - ), - _buildInfoRow( - context.l10n.map_path, - contact.pathLabel(context.l10n), - ), - if (contact.hasLocation) - _buildInfoRow( - context.l10n.map_location, - '${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}', - ) - else if (guessedPosition != null) - _buildInfoRow( - context.l10n.map_estLocation, - '~${guessedPosition.latitude.toStringAsFixed(6)}, ${guessedPosition.longitude.toStringAsFixed(6)}', - ), - _buildInfoRow( - context.l10n.map_lastSeen, - _formatLastSeen(contact.lastSeen), - ), - _buildInfoRow( - context.l10n.map_publicKey, - contact.publicKeyHex, - ), - const SizedBox(height: 16), - ...actions, - TextButton( - onPressed: () => Navigator.pop(sheetContext), - child: Text(context.l10n.common_close), - ), - ], - ), + ), + ], ), ), ); @@ -1798,7 +2794,13 @@ class _MapScreenState extends State { ), ), const SizedBox(height: 2), - SelectableText(value, style: const TextStyle(fontSize: 14)), + SelectableText( + value, + style: MeshTheme.mono( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + ), ], ), ); @@ -2057,7 +3059,9 @@ class _MapScreenState extends State { return ListTile( leading: Icon( isPublic ? Icons.public : Icons.tag, - color: isPublic ? Colors.orange : Colors.blue, + color: isPublic + ? MapPalette.cluster + : MapPalette.repeater, ), title: Text(label), onTap: () async { @@ -2115,164 +3119,205 @@ class _MapScreenState extends State { return result ?? false; } - void _showFilterDialog( + void _showFilterSheet( BuildContext context, AppSettingsService settingsService, ) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(context.l10n.map_filterNodes), - contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), - content: SingleChildScrollView( - child: Consumer( + showMeshSheet( + context, + builder: (sheetContext) => StatefulBuilder( + builder: (sheetContext, setSheetState) { + return Consumer( builder: (consumerContext, service, child) { final settings = service.settings; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.map_nodeTypes, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 8), - CheckboxListTile( - title: Text(context.l10n.map_chatNodes), - value: settings.mapShowChatNodes, - onChanged: (value) { - service.setMapShowChatNodes(value ?? true); - }, - contentPadding: EdgeInsets.zero, - ), - CheckboxListTile( - title: Text(context.l10n.map_repeaters), - value: settings.mapShowRepeaters, - onChanged: (value) { - service.setMapShowRepeaters(value ?? true); - }, - contentPadding: EdgeInsets.zero, - ), - CheckboxListTile( - title: Text(context.l10n.map_otherNodes), - value: settings.mapShowOtherNodes, - onChanged: (value) { - service.setMapShowOtherNodes(value ?? true); - }, - contentPadding: EdgeInsets.zero, - ), - CheckboxListTile( - title: Text(context.l10n.map_showGuessedLocations), - value: settings.mapShowGuessedLocations, - onChanged: (value) { - service.setMapShowGuessedLocations(value ?? true); - }, - contentPadding: EdgeInsets.zero, - ), - CheckboxListTile( - title: Text(context.l10n.map_showDiscoveryContacts), - value: settings.mapShowDiscoveryContacts, - onChanged: (value) { - service.setMapShowDiscoveryContacts(value ?? true); - }, - contentPadding: EdgeInsets.zero, - ), - CheckboxListTile( - title: Text(context.l10n.map_showOverlaps), - value: settings.mapShowOverlaps, - onChanged: (value) { - service.setMapShowOverlaps(value ?? true); - }, - contentPadding: EdgeInsets.zero, - ), + final scheme = Theme.of(sheetContext).colorScheme; - const SizedBox(height: 16), - Text( - context.l10n.map_keyPrefix, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + Widget freshnessChip(_Freshness value, String label) { + final selected = _freshness == value; + final accent = switch (value) { + _Freshness.all => MapPalette.selected, + _Freshness.online => MapPalette.online, + _Freshness.recent => MapPalette.stale, + _Freshness.stale => MapPalette.offline, + }; + return FilterChip( + label: Text(label), + selected: selected, + showCheckmark: true, + checkmarkColor: accent, + backgroundColor: scheme.surfaceContainerLow, + selectedColor: Color.alphaBlend( + accent.withValues(alpha: 0.22), + scheme.surfaceContainerHigh, + ), + side: BorderSide( + color: selected ? accent : scheme.outline, + width: selected ? 1.5 : 1, + ), + labelStyle: TextStyle( + color: selected + ? scheme.onSurface + : scheme.onSurfaceVariant, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + ), + onSelected: (_) { + setSheetState(() {}); + setState(() => _freshness = value); + }, + ); + } + + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.8, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BottomSheetHeader( + title: sheetContext.l10n.map_filterNodes, + ), + SectionHeader(sheetContext.l10n.map_activity), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: [ + freshnessChip( + _Freshness.all, + sheetContext.l10n.time_allTime, + ), + freshnessChip( + _Freshness.online, + sheetContext.l10n.map_online, + ), + freshnessChip( + _Freshness.recent, + sheetContext.l10n.map_recent, + ), + freshnessChip( + _Freshness.stale, + sheetContext.l10n.map_stale, + ), + ], + ), + ), + SectionHeader( + sheetContext.l10n.map_lastSeenTime, + trailing: Text( + _getTimeFilterLabel(settings.mapTimeFilterHours), + style: MeshTheme.mono( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Slider( + value: _hoursToSliderValue( + settings.mapTimeFilterHours, + ), + min: 0, + max: 100, + divisions: 100, + onChanged: (value) { + final hours = _sliderValueToHours(value); + service.setMapTimeFilterHours(hours); + }, + ), + ), + SectionHeader(sheetContext.l10n.map_nodeTypes), + SwitchListTile( + title: Text(sheetContext.l10n.map_chatNodes), + value: settings.mapShowChatNodes, + dense: true, + onChanged: (value) => + service.setMapShowChatNodes(value), + ), + SwitchListTile( + title: Text(sheetContext.l10n.map_repeaters), + value: settings.mapShowRepeaters, + dense: true, + onChanged: (value) => + service.setMapShowRepeaters(value), + ), + SwitchListTile( + title: Text(sheetContext.l10n.map_otherNodes), + value: settings.mapShowOtherNodes, + dense: true, + onChanged: (value) => + service.setMapShowOtherNodes(value), + ), + SectionHeader(sheetContext.l10n.map_markers), + SwitchListTile( + title: Text(sheetContext.l10n.map_showSharedMarkers), + value: settings.mapShowMarkers, + dense: true, + onChanged: (value) => + service.setMapShowMarkers(value), + ), + SwitchListTile( + title: Text( + sheetContext.l10n.map_showGuessedLocations, + ), + value: settings.mapShowGuessedLocations, + dense: true, + onChanged: (value) => + service.setMapShowGuessedLocations(value), + ), + SwitchListTile( + title: Text( + sheetContext.l10n.map_showDiscoveryContacts, + ), + value: settings.mapShowDiscoveryContacts, + dense: true, + onChanged: (value) => + service.setMapShowDiscoveryContacts(value), + ), + SwitchListTile( + title: Text(sheetContext.l10n.map_showOverlaps), + value: settings.mapShowOverlaps, + dense: true, + onChanged: (value) => + service.setMapShowOverlaps(value), + ), + SectionHeader(sheetContext.l10n.map_keyPrefix), + SwitchListTile( + title: Text(sheetContext.l10n.map_filterByKeyPrefix), + value: settings.mapKeyPrefixEnabled, + dense: true, + onChanged: (value) => + service.setMapKeyPrefixEnabled(value), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 20), + child: TextFormField( + initialValue: settings.mapKeyPrefix, + enabled: settings.mapKeyPrefixEnabled, + decoration: InputDecoration( + labelText: sheetContext.l10n.map_publicKeyPrefix, + hintText: + sheetContext.l10n.map_publicKeyPrefixHint, + isDense: true, + ), + style: MeshTheme.mono(fontSize: 13), + onChanged: (value) => + service.setMapKeyPrefix(value), + ), + ), + ], ), ), - const SizedBox(height: 8), - CheckboxListTile( - title: Text(context.l10n.map_filterByKeyPrefix), - value: settings.mapKeyPrefixEnabled, - onChanged: (value) { - service.setMapKeyPrefixEnabled(value ?? false); - }, - contentPadding: EdgeInsets.zero, - ), - TextFormField( - initialValue: settings.mapKeyPrefix, - enabled: settings.mapKeyPrefixEnabled, - decoration: InputDecoration( - labelText: context.l10n.map_publicKeyPrefix, - hintText: context.l10n.map_publicKeyPrefixHint, - border: const OutlineInputBorder(), - isDense: true, - ), - onChanged: (value) { - service.setMapKeyPrefix(value); - }, - ), - const SizedBox(height: 16), - Text( - context.l10n.map_markers, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 8), - CheckboxListTile( - title: Text(context.l10n.map_showSharedMarkers), - value: settings.mapShowMarkers, - onChanged: (value) { - service.setMapShowMarkers(value ?? true); - }, - contentPadding: EdgeInsets.zero, - ), - const SizedBox(height: 16), - Text( - context.l10n.map_lastSeenTime, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 8), - Text( - _getTimeFilterLabel(settings.mapTimeFilterHours), - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - Slider( - value: _hoursToSliderValue(settings.mapTimeFilterHours), - min: 0, - max: 100, - divisions: 100, - onChanged: (value) { - final hours = _sliderValueToHours(value); - service.setMapTimeFilterHours(hours); - }, - ), - ], + ), ); }, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(context.l10n.common_close), - ), - ], + ); + }, ), ); } @@ -2385,110 +3430,129 @@ class _MapScreenState extends State { top: 16, left: 16, right: 16, - child: Card( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.contacts_pathTrace, - style: TextStyle(fontWeight: FontWeight.bold), - ), - if (_pathTrace.isEmpty) const SizedBox(height: 8), - if (_pathTrace.isEmpty) - Text(l10n.map_tapToAdd, style: TextStyle(fontSize: 12)), - const SizedBox(height: 6), - if (_pathTrace.isNotEmpty) + child: DecoratedBox( + decoration: BoxDecoration( + color: MapPalette.panelDark, + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: MapPalette.border), + boxShadow: const [ + BoxShadow( + color: MapPalette.markerShadow, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(MeshRadii.md), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ Text( - "${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points), isImperial: isImperial)}", - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, + l10n.contacts_pathTrace, + style: TextStyle(fontWeight: FontWeight.bold), + ), + if (_pathTrace.isEmpty) const SizedBox(height: 8), + if (_pathTrace.isEmpty) + Text(l10n.map_tapToAdd, style: TextStyle(fontSize: 12)), + const SizedBox(height: 6), + if (_pathTrace.isNotEmpty) + Text( + "${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points), isImperial: isImperial)}", + style: MeshTheme.mono( + fontSize: 12, + color: MapPalette.textSecondary, + ), + ), + SelectableText( + _pathTrace + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(','), + style: MeshTheme.mono( + fontSize: 18, + fontWeight: FontWeight.w700, + color: MapPalette.selected, ), ), - SelectableText( - _pathTrace - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(','), - style: TextStyle(fontSize: 18), - ), - // const SizedBox(height: 6), - Wrap( - alignment: WrapAlignment.center, - spacing: 1, - runSpacing: 1, - children: [ - if (_pathTrace.isNotEmpty) - IconButton( - onPressed: () { - final hashW = context - .read() - .pathHashByteWidth; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PathTraceMapScreen( - title: l10n.contacts_pathTrace, - path: Uint8List.fromList(_pathTrace), - pathHashByteWidth: hashW, - pathContacts: _pathTraceContacts, + // const SizedBox(height: 6), + Wrap( + alignment: WrapAlignment.center, + spacing: 1, + runSpacing: 1, + children: [ + if (_pathTrace.isNotEmpty) + IconButton( + onPressed: () { + final hashW = context + .read() + .pathHashByteWidth; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PathTraceMapScreen( + title: l10n.contacts_pathTrace, + path: Uint8List.fromList(_pathTrace), + pathHashByteWidth: hashW, + pathContacts: _pathTraceContacts, + ), ), - ), - ); - setState(() { - _isBuildingPathTrace = false; - }); - }, - tooltip: l10n.map_runTrace, - icon: const Icon(Icons.arrow_forward_outlined), - ), - if (_pathTrace.isNotEmpty) - IconButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PathTraceMapScreen( - title: l10n.contacts_pathTrace, - path: Uint8List.fromList(_pathTrace), - flipPathAround: true, + ); + setState(() { + _isBuildingPathTrace = false; + }); + }, + tooltip: l10n.map_runTrace, + icon: const Icon(Icons.arrow_forward_outlined), + ), + if (_pathTrace.isNotEmpty) + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PathTraceMapScreen( + title: l10n.contacts_pathTrace, + path: Uint8List.fromList(_pathTrace), + flipPathAround: true, + ), ), - ), - ); - setState(() { - _isBuildingPathTrace = false; - }); - }, - tooltip: l10n.map_runTraceWithReturnPath, - icon: const Icon(Icons.replay), - ), - if (_pathTrace.isNotEmpty) - IconButton( - onPressed: _removePath, - tooltip: l10n.map_removeLast, - icon: const Icon(Icons.undo), - ), - if (_pathTrace.isEmpty) - IconButton( - onPressed: () { - setState(() { - _isBuildingPathTrace = false; - _pathTrace.clear(); - _points.clear(); - _polylines.clear(); - }); - showDismissibleSnackBar( - context, - content: Text(l10n.map_pathTraceCancelled), - ); - }, - tooltip: l10n.common_cancel, - icon: const Icon(Icons.close), - ), - ], - ), - ], + ); + setState(() { + _isBuildingPathTrace = false; + }); + }, + tooltip: l10n.map_runTraceWithReturnPath, + icon: const Icon(Icons.replay), + ), + if (_pathTrace.isNotEmpty) + IconButton( + onPressed: _removePath, + tooltip: l10n.map_removeLast, + icon: const Icon(Icons.undo), + ), + if (_pathTrace.isEmpty) + IconButton( + onPressed: () { + setState(() { + _isBuildingPathTrace = false; + _pathTrace.clear(); + _points.clear(); + _polylines.clear(); + }); + showDismissibleSnackBar( + context, + content: Text(l10n.map_pathTraceCancelled), + ); + }, + tooltip: l10n.common_cancel, + icon: const Icon(Icons.close), + ), + ], + ), + ], + ), ), ), ), @@ -2496,6 +3560,10 @@ class _MapScreenState extends State { } } +enum _NodeAge { online, recent, stale } + +enum _Freshness { all, online, recent, stale } + class _GuessedLocation { final Contact contact; final LatLng position; diff --git a/lib/screens/neighbors_screen.dart b/lib/screens/neighbors_screen.dart index c34af7f1..d043fbfe 100644 --- a/lib/screens/neighbors_screen.dart +++ b/lib/screens/neighbors_screen.dart @@ -9,9 +9,10 @@ import '../models/path_selection.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../services/repeater_command_service.dart'; +import '../theme/mesh_theme.dart'; import '../widgets/empty_state.dart'; +import '../widgets/mesh_ui.dart'; import '../widgets/routing_sheet.dart'; -import '../widgets/snr_indicator.dart'; import '../helpers/snack_bar_builder.dart'; class NeighborsScreen extends StatefulWidget { @@ -321,7 +322,7 @@ class _NeighborsScreenState extends State { child: RefreshIndicator( onRefresh: _loadNeighbors, child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ if (!_isLoaded && !_hasData && @@ -330,9 +331,7 @@ class _NeighborsScreenState extends State { if (_isLoaded || _hasData && !(_parsedNeighbors == null || _parsedNeighbors!.isEmpty)) - _buildNeighborsInfoCard( - "${l10n.repeater_neighbors} - $_neighborCount", - ), + _buildNeighborsList(connector), ], ), ), @@ -340,81 +339,100 @@ class _NeighborsScreenState extends State { ); } - Widget _buildNeighborsInfoCard(String title) { - final connector = Provider.of(context, listen: false); - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + Widget _buildNeighborsList(MeshCoreConnector connector) { + final l10n = context.l10n; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader( + '${l10n.repeater_neighbors} — $_neighborCount', + padding: const EdgeInsets.fromLTRB(4, 8, 4, 10), + ), + for (var i = 0; i < _parsedNeighbors!.length; i++) + ListEntrance( + index: i, + child: _buildNeighborRow( + _parsedNeighbors![i], + connector.currentSf, + ), + ), + ], + ); + } + + Widget _buildNeighborRow(Map data, int? spreadingFactor) { + final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; + final Contact? contact = data['contact'] as Contact?; + final double snr = data['snr'] as double; + final int lastHeardSeconds = data['lastHeard'] as int; + + final name = contact != null + ? contact.name + : l10n.neighbors_unknownContact( + '<${pubKeyToHex(data['publicKey'] as Uint8List)}>', + ); + + final snrColor = MeshTheme.snrColor(snr, blocked: false); + final heardLabel = l10n.neighbors_heardAgo( + fmtDuration(lastHeardSeconds + 0.0), + ); + + return MeshCard( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + margin: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + AvatarCircle( + name: name, + size: 40, + color: contact != null ? MeshPalette.warn : scheme.onSurfaceVariant, + icon: contact != null ? Icons.cell_tower : Icons.device_unknown, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.info_outline, - color: Theme.of(context).textTheme.headlineSmall?.color, - ), - const SizedBox(width: 8), Text( - title, + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, + fontSize: 15, + ), + ), + const SizedBox(height: 2), + Text( + heardLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), - const Divider(), - for (final entry in _parsedNeighbors!.asMap().entries) - _buildInfoRow( - entry.value['contact'] != null - ? entry.value['contact'].name - : context.l10n.neighbors_unknownContact( - "<${pubKeyToHex(entry.value['publicKey'])}>", - ), - context.l10n.neighbors_heardAgo( - fmtDuration(entry.value['lastHeard'] + 0.0), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + SignalBars(snr: snr, height: 16), + const SizedBox(height: 4), + Text( + '${snr.toStringAsFixed(1)} dB', + style: MeshTheme.mono( + fontSize: 11, + fontWeight: FontWeight.w600, + color: snrColor, ), - entry.value['snr'], - connector.currentSf, ), - ], - ), - ), - ); - } - - Widget _buildInfoRow( - String label, - String value, - double snr, - int? spreadingFactor, - ) { - final snrUi = snrUiFromSNR(snr, spreadingFactor); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ListTile( - contentPadding: EdgeInsets.zero, - title: Text( - label, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text(value), - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(snrUi.icon, color: snrUi.color, size: 18.0), - Text( - snrUi.text, - style: TextStyle(fontSize: 10, color: snrUi.color), - ), - ], - ), - ), + ], ), ], ), diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 99bc4deb..d955b30b 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -10,31 +10,21 @@ import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/l10n/l10n.dart'; import 'package:meshcore_open/models/app_settings.dart'; import 'package:meshcore_open/models/contact.dart'; +import 'package:meshcore_open/models/display_path.dart'; +import 'package:meshcore_open/models/path_history.dart'; +import 'package:meshcore_open/models/path_playback.dart'; import 'package:meshcore_open/services/app_settings_service.dart'; import 'package:meshcore_open/services/map_tile_cache_service.dart'; +import 'package:meshcore_open/services/path_history_service.dart'; import 'package:meshcore_open/utils/app_logger.dart'; +import 'package:meshcore_open/widgets/path_map_ui.dart'; import 'package:meshcore_open/widgets/snr_indicator.dart'; +import 'package:meshcore_open/widgets/themed_map_tile_layer.dart'; import 'package:provider/provider.dart'; +import '../theme/mesh_theme.dart'; -double getPathDistanceMeters(List points) { - if (points.length <= 1) return 0.0; - - double distanceMeters = 0.0; - final distanceCalculator = Distance(); - - for (int i = 0; i < points.length - 1; i++) { - distanceMeters += distanceCalculator(points[i], points[i + 1]); - } - - return distanceMeters; -} - -String formatDistance(double distanceMeters, {required bool isImperial}) { - if (isImperial) { - return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} mi)'; - } - return '(${(distanceMeters / 1000).toStringAsFixed(2)} km)'; -} +export 'package:meshcore_open/widgets/path_map_ui.dart' + show formatDistance, getPathDistanceMeters; class PathTraceData { final Uint8List pathData; @@ -74,7 +64,8 @@ class PathTraceMapScreen extends StatefulWidget { State createState() => _PathTraceMapScreenState(); } -class _PathTraceMapScreenState extends State { +class _PathTraceMapScreenState extends State + with SingleTickerProviderStateMixin { static const double _labelZoomThreshold = 8.5; static const double _mapMinZoom = 2.0; static const double _mapMaxZoom = 18.0; @@ -107,6 +98,18 @@ class _PathTraceMapScreenState extends State { // endpoint inference so it matches the path that was actually traced. Uint8List _tracedPath = Uint8List(0); + // Packet-flow animation + multi-path view state. + late final PathPlaybackController _playback; + PathHistoryService? _pathHistory; + PathViewMode _viewMode = PathViewMode.single; + List _displayPaths = []; + List _primaryOutboundHops = []; + String _selectedPathId = 'primary'; + final Set _hiddenPathIds = {}; + bool _panelCollapsed = false; + bool _animationEnabled = true; + bool _followPacket = false; + String _formatPathPrefixes(Uint8List pathBytes) { return pathBytes .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) @@ -116,18 +119,51 @@ class _PathTraceMapScreenState extends State { @override void initState() { super.initState(); + _playback = PathPlaybackController(this); + _playback.addListener(_followPacketCamera); + _pathHistory = context.read(); + _pathHistory!.addListener(_onPathHistoryChanged); _setupFrameListener(); _doPathTrace(); } @override void dispose() { + _pathHistory?.removeListener(_onPathHistoryChanged); + _playback.dispose(); _mapController.dispose(); _frameSubscription?.cancel(); _timeoutTimer?.cancel(); super.dispose(); } + void _onPathHistoryChanged() { + if (!mounted || !_hasData) return; + setState(() { + _rebuildDisplayPaths(context.read()); + }); + } + + /// Keeps the camera centered on the packet while the follow lock is on. + void _followPacketCamera() { + if (!_followPacket || + !_animationEnabled || + !_playback.started || + !_playback.hasPath || + !mounted || + !_hasData) { + return; + } + _mapController.move(_playback.position, _mapController.camera.zoom); + } + + void _toggleFollowPacket() { + setState(() { + _followPacket = !_followPacket; + }); + _followPacketCamera(); + } + bool _isDesktopPlatform(TargetPlatform platform) { return platform == TargetPlatform.linux || platform == TargetPlatform.windows || @@ -164,27 +200,34 @@ class _PathTraceMapScreenState extends State { return Positioned( top: 16, left: 16, - child: Card( - elevation: 4, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.add), - tooltip: context.l10n.map_zoomIn, - onPressed: () => _zoomMapBy(1), - ), - IconButton( - icon: const Icon(Icons.remove), - tooltip: context.l10n.map_zoomOut, - onPressed: () => _zoomMapBy(-1), - ), - IconButton( - icon: const Icon(Icons.my_location), - tooltip: context.l10n.map_centerMap, - onPressed: _resetMapView, - ), - ], + child: DecoratedBox( + decoration: BoxDecoration( + color: MeshPalette.bg1.withValues(alpha: 0.90), + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: MeshPalette.line2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(MeshRadii.md), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: context.l10n.map_zoomIn, + onPressed: () => _zoomMapBy(1), + ), + IconButton( + icon: const Icon(Icons.remove), + tooltip: context.l10n.map_zoomOut, + onPressed: () => _zoomMapBy(-1), + ), + IconButton( + icon: const Icon(Icons.my_location), + tooltip: context.l10n.map_centerMap, + onPressed: _resetMapView, + ), + ], + ), ), ), ); @@ -249,6 +292,7 @@ class _PathTraceMapScreenState extends State { } Future _doPathTrace() async { + _playback.stop(); if (mounted) { setState(() { _isLoading = true; @@ -542,6 +586,8 @@ class _PathTraceMapScreenState extends State { '${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}', ); _pathDistanceMeters = getPathDistanceMeters(_points); + _primaryOutboundHops = _outboundHops(pathData); + _rebuildDisplayPaths(connector); }); } catch (e) { appLogger.error( @@ -557,6 +603,252 @@ class _PathTraceMapScreenState extends State { } } + /// Outbound hop bytes of the traced path, mirroring the round-trip + /// dedup logic used when building [_points]. + List _outboundHops(Uint8List pathData) { + final hops = []; + int hopLast = 0; + int hopLastLast = 0; + for (final hop in pathData) { + if (hop == hopLastLast && widget.flipPathAround) break; + hops.add(hop); + hopLastLast = hopLast; + hopLast = hop; + } + return hops; + } + + Contact? _contactForHop(int hop, MeshCoreConnector connector) { + final traced = _traceData?.pathContacts[hop]; + if (traced != null) return traced; + for (final c in connector.allContactsUnfiltered) { + if (c.type != advTypeChat && + c.publicKey.isNotEmpty && + c.publicKey[0] == hop) { + return c; + } + } + return null; + } + + LatLng? _inferredPositionForHop(int hop, MeshCoreConnector connector) { + final cached = _inferredHopPositions[hop]; + if (cached != null) return cached; + final peers = connector.contacts + .where((c) => c.hasLocation && c.path.isNotEmpty && c.path.last == hop) + .toList(); + if (peers.isEmpty) return null; + final lat = + peers.map((c) => c.latitude!).reduce((a, b) => a + b) / peers.length; + final lon = + peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.length; + final pos = LatLng(lat, lon); + _inferredHopPositions[hop] = pos; + return pos; + } + + /// Rebuilds the renderable paths: the traced path as primary plus up to + /// four distinct alternates from the target contact's path history. + void _rebuildDisplayPaths(MeshCoreConnector connector) { + final paths = []; + final primary = _buildDisplayPath( + id: 'primary', + label: context.l10n.pathMap_primary, + color: kPrimaryPathColor, + isPrimary: true, + hops: _primaryOutboundHops, + connector: connector, + ); + if (primary != null) paths.add(primary); + + final target = widget.targetContact; + final history = _pathHistory; + if (target != null && history != null) { + final seen = {_primaryOutboundHops.join(',')}; + var altIndex = 0; + for (final record in history.getRecentPaths(target.publicKeyHex)) { + if (record.pathBytes.isEmpty) continue; + if (!seen.add(record.pathBytes.join(','))) continue; + if (altIndex >= kAlternatePathColors.length) break; + final alt = _buildDisplayPath( + id: 'alt-${record.pathBytes.join('-')}', + label: context.l10n.pathMap_alternate(altIndex + 1), + color: kAlternatePathColors[altIndex], + isPrimary: false, + hops: record.pathBytes, + record: record, + connector: connector, + ); + if (alt != null) { + paths.add(alt); + altIndex++; + } + } + } + + _displayPaths = paths; + _hiddenPathIds.removeWhere((id) => !paths.any((p) => p.id == id)); + if (!paths.any((p) => p.id == _selectedPathId)) { + _selectedPathId = paths.isNotEmpty ? paths.first.id : 'primary'; + } + if (paths.length < 2) _viewMode = PathViewMode.single; + _syncPlaybackToSelection(); + } + + DisplayPath? _buildDisplayPath({ + required String id, + required String label, + required Color color, + required bool isPrimary, + required List hops, + required MeshCoreConnector connector, + PathRecord? record, + }) { + final selfLat = connector.selfLatitude; + final selfLon = connector.selfLongitude; + if (selfLat == null || selfLon == null) return null; + + final points = [LatLng(selfLat, selfLon)]; + final labels = [context.l10n.pathTrace_you]; + final confirmed = [true]; + final hopOrdinals = [-1]; + final gapBefore = [false]; + int gpsConfirmedHops = 0; + int unresolvedHops = 0; + bool pendingGap = false; + + for (var i = 0; i < hops.length; i++) { + final hop = hops[i]; + final hex = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + final contact = _contactForHop(hop, connector); + LatLng? pos; + var isGps = false; + if (contact != null && contact.hasLocation) { + pos = LatLng(contact.latitude!, contact.longitude!); + isGps = true; + gpsConfirmedHops++; + } else { + pos = _inferredPositionForHop(hop, connector); + } + if (pos == null) { + unresolvedHops++; + pendingGap = true; + continue; + } + points.add(pos); + labels.add(contact?.name ?? '~$hex'); + confirmed.add(isGps); + hopOrdinals.add(i); + gapBefore.add(pendingGap); + pendingGap = false; + } + + // Append the chat-target endpoint the same way the traced path does. + final target = widget.targetContact; + final targetPos = _targetContactPosition; + final hasTargetEndpoint = + target != null && target.type == advTypeChat && targetPos != null; + if (hasTargetEndpoint) { + points.add(targetPos); + labels.add(target.name); + confirmed.add(!_targetContactIsGuessed); + hopOrdinals.add(hops.length); + gapBefore.add(pendingGap); + pendingGap = false; + } + + if (points.length < 2) return null; + + final segmentEstimated = []; + final rowForSegment = []; + for (var i = 0; i < points.length - 1; i++) { + segmentEstimated.add( + !confirmed[i] || !confirmed[i + 1] || gapBefore[i + 1], + ); + rowForSegment.add(hopOrdinals[i + 1] < 0 ? 0 : hopOrdinals[i + 1]); + } + + return DisplayPath( + id: id, + label: label, + color: color, + isPrimary: isPrimary, + hopBytes: List.from(hops), + points: points, + pointLabels: labels, + pointConfirmed: confirmed, + segmentEstimated: segmentEstimated, + rowForSegment: rowForSegment, + totalTransmissions: hops.length + (hasTargetEndpoint ? 1 : 0), + hasTargetEndpoint: hasTargetEndpoint, + gpsConfirmedHops: gpsConfirmedHops, + unresolvedHops: unresolvedHops, + distanceMeters: getPathDistanceMeters(points), + record: record, + ); + } + + DisplayPath? get _selectedPath { + if (_displayPaths.isEmpty) return null; + return _displayPaths.firstWhere( + (p) => p.id == _selectedPathId, + orElse: () => _displayPaths.first, + ); + } + + List get _visiblePaths { + if (_viewMode == PathViewMode.single) { + final selected = _selectedPath; + return selected != null ? [selected] : const []; + } + return _displayPaths + .where((p) => !_hiddenPathIds.contains(p.id)) + .toList(); + } + + /// Updates the playback path, but only when the selected path's geometry + /// actually changed, so unrelated path-history updates don't reset a + /// running animation. + void _syncPlaybackToSelection() { + final points = _selectedPath?.points ?? const []; + if (points.length == _playback.points.length) { + var same = true; + for (var i = 0; i < points.length; i++) { + if (points[i] != _playback.points[i]) { + same = false; + break; + } + } + if (same) return; + } + _playback.setPath(points); + } + + void _selectPath(DisplayPath path) { + setState(() { + _selectedPathId = path.id; + _hiddenPathIds.remove(path.id); + _syncPlaybackToSelection(); + }); + } + + void _togglePathVisibility(DisplayPath path) { + setState(() { + if (!_hiddenPathIds.remove(path.id)) { + _hiddenPathIds.add(path.id); + if (path.id == _selectedPathId) { + final visible = _displayPaths.where( + (p) => !_hiddenPathIds.contains(p.id), + ); + if (visible.isNotEmpty) { + _selectedPathId = visible.first.id; + _syncPlaybackToSelection(); + } + } + } + }); + } + @override Widget build(BuildContext context) { return Consumer( @@ -564,23 +856,12 @@ class _PathTraceMapScreenState extends State { final settings = context.watch().settings; final isImperial = settings.unitSystem == UnitSystem.imperial; final tileCache = context.read(); + final scheme = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - FittedBox( - fit: BoxFit.scaleDown, - child: Text( - widget.title, - style: const TextStyle(fontSize: 24), - ), - ), - ], - ), - centerTitle: false, + title: Text(widget.title), + centerTitle: true, actions: [ IconButton( icon: _isLoading @@ -604,10 +885,14 @@ class _PathTraceMapScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (_isLoading) const CircularProgressIndicator(), + if (_isLoading) + CircularProgressIndicator(color: MeshPalette.blue), const SizedBox(height: 16), if (!_isLoading && _failed2Loaded) - Text(context.l10n.pathTrace_notAvailable), + Text( + context.l10n.pathTrace_notAvailable, + style: TextStyle(color: scheme.onSurfaceVariant), + ), ], ), ), @@ -615,25 +900,33 @@ class _PathTraceMapScreenState extends State { _buildMapPathTrace(context, tileCache, _targetContact), if (_hasData && _isDesktopPlatform(defaultTargetPlatform)) _buildDesktopMapControls(), + if (_hasData && _displayPaths.length > 1) + PathViewModeToggle( + mode: _viewMode, + onChanged: (mode) => setState(() => _viewMode = mode), + ), if (_points.isEmpty && !_hasData && !_isLoading && !_failed2Loaded) Center( - child: Card( - color: Theme.of( - context, - ).colorScheme.surface.withValues(alpha: 0.9), + child: DecoratedBox( + decoration: BoxDecoration( + color: scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: scheme.outlineVariant), + ), child: Padding( - padding: EdgeInsets.all(12), + padding: const EdgeInsets.all(12), child: Text( context.l10n.channelPath_noRepeaterLocations, + style: TextStyle(color: scheme.onSurfaceVariant), ), ), ), ), if (_hasData) - _buildLegendCard(context, _traceData!, isImperial), + _buildBottomPanel(context, _traceData!, isImperial), ], ), ), @@ -676,28 +969,33 @@ class _PathTraceMapScreenState extends State { child: Container( width: 35, height: 35, - padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: hasGps - ? Colors.green - : Colors.orange.withValues(alpha: 0.75), shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), + color: hasGps + ? MeshPalette.signal.withValues(alpha: 0.18) + : MeshPalette.warn.withValues(alpha: 0.18), + border: Border.all( + color: hasGps + ? MeshPalette.signal.withValues(alpha: 0.7) + : MeshPalette.warn.withValues(alpha: 0.7), + width: 1.5, + ), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), + color: hasGps + ? MeshPalette.signal.withValues(alpha: 0.3) + : MeshPalette.warn.withValues(alpha: 0.3), + blurRadius: 5, ), ], ), alignment: Alignment.center, child: Text( hasGps ? label : '~$label', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, + style: MeshTheme.mono( + fontSize: 10, + fontWeight: FontWeight.w700, + color: hasGps ? MeshPalette.signal : MeshPalette.warn, ), ), ), @@ -716,6 +1014,17 @@ class _PathTraceMapScreenState extends State { hopLast = hop; } + _addEndpointMarkers(markers, showLabels: showLabels, target: target); + + return markers; + } + + /// Self and target endpoint markers, shared by single and combined views. + void _addEndpointMarkers( + List markers, { + required bool showLabels, + required Contact? target, + }) { final selfLat = context.read().selfLatitude; final selfLon = context.read().selfLongitude; if (selfLat != null && selfLon != null) { @@ -729,26 +1038,27 @@ class _PathTraceMapScreenState extends State { child: Container( width: 35, height: 35, - padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: Colors.blue, shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), + color: MeshPalette.blue.withValues(alpha: 0.18), + border: Border.all( + color: MeshPalette.blue.withValues(alpha: 0.7), + width: 2, + ), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), + color: MeshPalette.blue.withValues(alpha: 0.35), + blurRadius: 6, ), ], ), alignment: Alignment.center, child: Text( context.l10n.pathTrace_you, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, + style: MeshTheme.mono( + fontSize: 9, + fontWeight: FontWeight.w700, + color: MeshPalette.blue, ), ), ), @@ -779,23 +1089,32 @@ class _PathTraceMapScreenState extends State { child: Container( width: 35, height: 35, - padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: isGuessed - ? Colors.purple.withValues(alpha: 0.55) - : Colors.red, shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), + color: isGuessed + ? MeshPalette.magenta.withValues(alpha: 0.18) + : MeshPalette.alert.withValues(alpha: 0.18), + border: Border.all( + color: isGuessed + ? MeshPalette.magenta.withValues(alpha: 0.7) + : MeshPalette.alert.withValues(alpha: 0.7), + width: 1.5, + ), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), + color: isGuessed + ? MeshPalette.magenta.withValues(alpha: 0.3) + : MeshPalette.alert.withValues(alpha: 0.3), + blurRadius: 5, ), ], ), alignment: Alignment.center, - child: const Icon(Icons.person, color: Colors.white, size: 18), + child: Icon( + Icons.person, + color: isGuessed ? MeshPalette.magenta : MeshPalette.alert, + size: 18, + ), ), ), ), @@ -809,10 +1128,133 @@ class _PathTraceMapScreenState extends State { ); } } + } + + /// Markers for the union of hops across all visible paths, with a badge on + /// repeaters used by more than one path. + List _buildCombinedHopMarkers({ + required bool showLabels, + required Contact? target, + }) { + final connector = context.read(); + final markers = []; + + // Hop byte -> paths that use it, in display order. + final hopPaths = >{}; + for (final path in _visiblePaths) { + for (final hop in path.hopBytes) { + final list = hopPaths.putIfAbsent(hop, () => []); + if (!list.contains(path)) list.add(path); + } + } + + for (final entry in hopPaths.entries) { + final hop = entry.key; + final paths = entry.value; + final contact = _contactForHop(hop, connector); + final hasGps = contact != null && contact.hasLocation; + final point = hasGps + ? LatLng(contact.latitude!, contact.longitude!) + : _inferredPositionForHop(hop, connector); + if (point == null) continue; + final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + final baseColor = hasGps ? MeshPalette.signal : MeshPalette.warn; + final shared = paths.length > 1; + + markers.add( + Marker( + point: point, + width: 48, + height: 48, + child: GestureDetector( + onTap: () => _showSharedNodeSheet(hop, contact, paths), + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 35, + height: 35, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: baseColor.withValues(alpha: 0.18), + border: Border.all( + color: baseColor.withValues(alpha: 0.7), + width: shared ? 2.5 : 1.5, + ), + boxShadow: [ + BoxShadow( + color: baseColor.withValues(alpha: 0.3), + blurRadius: 5, + ), + ], + ), + alignment: Alignment.center, + child: Text( + hasGps ? label : '~$label', + style: MeshTheme.mono( + fontSize: 10, + fontWeight: FontWeight.w700, + color: baseColor, + ), + ), + ), + if (shared) + Positioned( + top: 0, + right: 0, + child: Container( + width: 17, + height: 17, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: MeshPalette.bg1, + border: Border.all(color: MeshPalette.line3), + ), + alignment: Alignment.center, + child: Text( + '${paths.length}', + style: MeshTheme.mono( + fontSize: 9, + fontWeight: FontWeight.w700, + color: MeshPalette.ink, + ), + ), + ), + ), + ], + ), + ), + ), + ); + if (showLabels) { + markers.add( + _buildNodeLabelMarker( + point: point, + label: contact?.name ?? '~$label', + ), + ); + } + } + + _addEndpointMarkers(markers, showLabels: showLabels, target: target); return markers; } + void _showSharedNodeSheet( + int hop, + Contact? contact, + List paths, + ) { + final hex = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + showSharedNodeSheet( + context, + title: '$hex: ${contact?.name ?? context.l10n.channelPath_unknownRepeater}', + paths: paths, + onSelect: _selectPath, + ); + } + Marker _buildNodeLabelMarker({required LatLng point, required String label}) { return Marker( point: point, @@ -827,18 +1269,19 @@ class _PathTraceMapScreenState extends State { child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), + color: MeshPalette.bg.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(MeshRadii.xs), + border: Border.all(color: MeshPalette.line, width: 0.5), ), alignment: Alignment.center, child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 11, + style: MeshTheme.mono( + fontSize: 10, fontWeight: FontWeight.w500, + color: MeshPalette.ink2, ), ), ), @@ -941,8 +1384,15 @@ class _PathTraceMapScreenState extends State { minZoom: _mapMinZoom, maxZoom: _mapMaxZoom, onPositionChanged: (camera, hasGesture) { + if (!mounted) return; + // A manual pan/zoom releases the follow lock. + if (hasGesture && _followPacket) { + setState(() { + _followPacket = false; + }); + } final shouldShow = camera.zoom >= _labelZoomThreshold; - if (shouldShow != _showNodeLabels && mounted) { + if (shouldShow != _showNodeLabels) { setState(() { _showNodeLabels = shouldShow; }); @@ -950,14 +1400,23 @@ class _PathTraceMapScreenState extends State { }, ), children: [ - TileLayer( - urlTemplate: kMapTileUrlTemplate, - tileProvider: tileCache.tileProvider, - userAgentPackageName: MapTileCacheService.userAgentPackageName, - maxZoom: 19, + ThemedMapTileLayer(tileCache: tileCache), + AnimatedBuilder( + animation: _playback, + builder: (context, _) { + final lines = _buildDisplayPolylines(); + if (lines.isEmpty) return const SizedBox.shrink(); + return PolylineLayer(polylines: lines); + }, ), - if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines), - if (_traceData!.pathData.isNotEmpty) + if (_viewMode == PathViewMode.combined) + MarkerLayer( + markers: _buildCombinedHopMarkers( + showLabels: _showNodeLabels, + target: target, + ), + ) + else if (_traceData!.pathData.isNotEmpty) MarkerLayer( markers: _buildHopMarkers( _traceData!.pathData, @@ -965,25 +1424,70 @@ class _PathTraceMapScreenState extends State { target: target, ), ), + AnimatedBuilder( + animation: _playback, + builder: (context, _) { + final markers = _buildPacketMarkers(); + if (markers.isEmpty) return const SizedBox.shrink(); + return MarkerLayer(markers: markers); + }, + ), ], ); } - Widget _colorDot(Color color) => Container( - width: 10, - height: 10, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ); + /// Polylines for the visible paths. While the packet animation is running, + /// the selected path's base line is dimmed and the traversed portion plus + /// the active segment are redrawn brightly by the playback overlay. + List _buildDisplayPolylines() { + final visible = _visiblePaths; + if (_displayPaths.isEmpty) return List.of(_polylines); + if (visible.isEmpty) return const []; - Widget _buildLegendCard( + final selected = _selectedPath; + final animating = + _animationEnabled && _playback.started && _playback.hasPath; + + final lines = buildMultiPathPolylines( + visible: visible, + selected: selected, + combined: _viewMode == PathViewMode.combined, + animating: animating, + ); + if (animating && selected != null) { + lines.addAll(buildPacketTrailPolylines(_playback, selected.color)); + } + return lines; + } + + List _buildPacketMarkers() { + final selected = _selectedPath; + if (!_animationEnabled || selected == null) return const []; + return buildPacketMarkers(_playback, selected.color); + } + + Widget _buildBottomPanel( BuildContext context, PathTraceData pathTraceData, bool isImperial, ) { final l10n = context.l10n; - final maxHeight = MediaQuery.of(context).size.height * 0.35; - final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0); - final cardHeight = max(96.0, min(maxHeight, estimatedHeight)); + final selected = _selectedPath; + final combined = _viewMode == PathViewMode.combined; + final maxHeight = + MediaQuery.of(context).size.height * (combined ? 0.45 : 0.35); + + double cardHeight; + if (_panelCollapsed) { + cardHeight = 128; + } else { + final summaryHeight = combined ? 34.0 + _displayPaths.length * 36.0 : 0; + final hopRows = combined + ? (selected?.totalTransmissions ?? 0) + : pathTraceData.pathData.length + 1; + final estimatedHeight = 132.0 + summaryHeight + hopRows * 56.0; + cardHeight = max(176.0, min(maxHeight, estimatedHeight)); + } return Positioned( left: 16, @@ -991,107 +1495,276 @@ class _PathTraceMapScreenState extends State { bottom: 16, child: SizedBox( height: cardHeight, - child: Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters, isImperial: isImperial)}', - style: const TextStyle(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 6), - Row( - children: [ - _colorDot(Colors.green), - const SizedBox(width: 4), - Text( - l10n.pathTrace_legendGpsConfirmed, - style: const TextStyle(fontSize: 11), - ), - const SizedBox(width: 12), - _colorDot(Colors.orange), - const SizedBox(width: 4), - Text( - l10n.pathTrace_legendInferred, - style: const TextStyle(fontSize: 11), - ), - ], - ), - ], - ), - ), - const Divider(height: 1), - Expanded( - child: pathTraceData.pathData.isEmpty - ? Center( - child: Text(l10n.channelPath_noHopDetailsAvailable), - ) - : Scrollbar( - child: ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: pathTraceData.pathData.length + 1, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final snrUi = snrUiFromSNR( - index < pathTraceData.snrData.length - ? pathTraceData.snrData[index] - : null, - context.read().currentSf, - ); - return Column( - children: [ - ListTile( - leading: - index >= pathTraceData.snrData.length / 2 - ? Icon(Icons.call_received) - : Icon(Icons.call_made), - title: Text( - formatDirectionText(pathTraceData, index), - style: const TextStyle(fontSize: 14), - ), - subtitle: Text( - formatDirectionSubText( - pathTraceData, - index, - ), - style: const TextStyle(fontSize: 14), - ), - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - snrUi.icon, - color: snrUi.color, - size: 18.0, - ), - Text( - snrUi.text, - style: TextStyle( - fontSize: 10, - color: snrUi.color, - ), - ), - ], - ), - onTap: () { - // Handle item tap - }, - ), - ], - ); - }, + child: DecoratedBox( + decoration: BoxDecoration( + color: MeshPalette.bg1.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all(color: MeshPalette.line2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(MeshRadii.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 4, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${l10n.channelPath_repeaterHops} ${formatDistance(selected?.distanceMeters ?? _pathDistanceMeters, isImperial: isImperial)}', + style: MeshTheme.mono( + fontWeight: FontWeight.w600, + fontSize: 13, + color: MeshPalette.ink, + ), + ), + const SizedBox(height: 4), + PathMiniLegend(combined: combined), + ], ), ), - ), - ], + IconButton( + visualDensity: VisualDensity.compact, + icon: Icon( + _panelCollapsed + ? Icons.expand_less + : Icons.expand_more, + size: 20, + ), + tooltip: _panelCollapsed + ? l10n.pathMap_expandPanel + : l10n.pathMap_collapsePanel, + onPressed: () => setState( + () => _panelCollapsed = !_panelCollapsed, + ), + ), + ], + ), + ), + PathAnimationControls( + playback: _playback, + selected: selected, + animationEnabled: _animationEnabled, + onToggleAnimation: () => setState(() { + _animationEnabled = !_animationEnabled; + if (!_animationEnabled) _playback.stop(); + }), + followEnabled: _followPacket, + onToggleFollow: _toggleFollowPacket, + ), + if (!_panelCollapsed) ...[ + if (selected != null && selected.unresolvedHops > 0) + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), + child: Text( + l10n.pathMap_partialAnimation(selected.unresolvedHops), + style: TextStyle( + fontSize: 10.5, + color: MeshPalette.warn, + ), + ), + ), + if (combined) + PathSummaryList( + paths: _displayPaths, + selectedId: _selectedPathId, + hiddenIds: _hiddenPathIds, + isImperial: isImperial, + onSelect: _selectPath, + onToggleVisibility: _togglePathVisibility, + onShowAll: () => setState(_hiddenPathIds.clear), + ), + const Divider(height: 1), + Expanded(child: _buildHopList(pathTraceData, selected)), + ], + ], + ), ), ), ), ); } + + Widget _buildHopList(PathTraceData pathTraceData, DisplayPath? selected) { + final useSnrList = + _viewMode == PathViewMode.single && (selected?.isPrimary ?? true); + return ValueListenableBuilder( + valueListenable: _playback.activeSegment, + builder: (context, activeSegment, _) { + int highlightRow = -1; + if (_animationEnabled && + selected != null && + activeSegment >= 0 && + activeSegment < selected.rowForSegment.length) { + highlightRow = selected.rowForSegment[activeSegment]; + } + if (useSnrList) { + return _buildSnrHopList(pathTraceData, highlightRow); + } + if (selected == null) { + return Center( + child: Text(context.l10n.channelPath_noHopDetailsAvailable), + ); + } + return _buildGenericHopList(selected, pathTraceData, highlightRow); + }, + ); + } + + Widget _buildSnrHopList(PathTraceData pathTraceData, int highlightRow) { + final l10n = context.l10n; + if (pathTraceData.pathData.isEmpty) { + return Center(child: Text(l10n.channelPath_noHopDetailsAvailable)); + } + return Scrollbar( + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: pathTraceData.pathData.length + 1, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final snrUi = snrUiFromSNR( + index < pathTraceData.snrData.length + ? pathTraceData.snrData[index] + : null, + context.read().currentSf, + ); + return ListTile( + tileColor: index == highlightRow + ? kPrimaryPathColor.withValues(alpha: 0.14) + : null, + leading: index >= pathTraceData.snrData.length / 2 + ? Icon(Icons.call_received) + : Icon(Icons.call_made), + title: Text( + formatDirectionText(pathTraceData, index), + style: MeshTheme.mono(fontSize: 13, color: MeshPalette.ink), + ), + subtitle: Text( + formatDirectionSubText(pathTraceData, index), + style: MeshTheme.mono(fontSize: 12, color: MeshPalette.ink3), + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(snrUi.icon, color: snrUi.color, size: 18.0), + Text( + snrUi.text, + style: MeshTheme.mono(fontSize: 10, color: snrUi.color), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildGenericHopList( + DisplayPath path, + PathTraceData pathTraceData, + int highlightRow, + ) { + final connector = context.read(); + final l10n = context.l10n; + + final hopUseCount = {}; + if (_viewMode == PathViewMode.combined) { + for (final p in _visiblePaths) { + for (final hop in p.hopBytes.toSet()) { + hopUseCount.update(hop, (v) => v + 1, ifAbsent: () => 1); + } + } + } + + return Scrollbar( + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: path.totalTransmissions, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + String title; + String subtitle; + Widget? trailing; + if (index < path.hopBytes.length) { + final hop = path.hopBytes[index]; + final hex = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + final contact = _contactForHop(hop, connector); + title = contact != null + ? '$hex: ${contact.name}' + : '$hex: ${l10n.channelPath_unknownRepeater}'; + final hasGps = contact != null && contact.hasLocation; + final inferred = + !hasGps && _inferredPositionForHop(hop, connector) != null; + final status = hasGps + ? l10n.pathTrace_legendGpsConfirmed + : inferred + ? l10n.pathTrace_legendInferred + : l10n.pathMap_noLocation; + final sharedCount = hopUseCount[hop] ?? 0; + subtitle = sharedCount > 1 + ? '$status · ${l10n.pathMap_sharedNodeCount(sharedCount)}' + : status; + if (path.isPrimary && index < pathTraceData.snrData.length) { + final snrUi = snrUiFromSNR( + pathTraceData.snrData[index], + connector.currentSf, + ); + trailing = Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(snrUi.icon, color: snrUi.color, size: 18.0), + Text( + snrUi.text, + style: MeshTheme.mono(fontSize: 10, color: snrUi.color), + ), + ], + ); + } + } else { + title = widget.targetContact?.name ?? ''; + subtitle = _targetContactIsGuessed + ? l10n.pathTrace_legendInferred + : l10n.pathTrace_legendGpsConfirmed; + } + return ListTile( + dense: true, + tileColor: index == highlightRow + ? path.color.withValues(alpha: 0.14) + : null, + leading: Container( + width: 26, + height: 26, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: path.color, width: 1.5), + ), + alignment: Alignment.center, + child: Text( + '${index + 1}', + style: MeshTheme.mono( + fontSize: 11, + fontWeight: FontWeight.w700, + color: path.color, + ), + ), + ), + title: Text( + title, + style: MeshTheme.mono(fontSize: 13, color: MeshPalette.ink), + ), + subtitle: Text( + subtitle, + style: MeshTheme.mono(fontSize: 11, color: MeshPalette.ink3), + ), + trailing: trailing, + ); + }, + ), + ); + } } diff --git a/lib/screens/repeater_cli_screen.dart b/lib/screens/repeater_cli_screen.dart index 80269061..5ed1b4eb 100644 --- a/lib/screens/repeater_cli_screen.dart +++ b/lib/screens/repeater_cli_screen.dart @@ -1,11 +1,12 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../theme/mesh_theme.dart'; import '../widgets/debug_frame_viewer.dart'; import '../services/repeater_command_service.dart'; import '../widgets/routing_sheet.dart'; @@ -34,7 +35,6 @@ class _RepeaterCliScreenState extends State { StreamSubscription? _frameSubscription; RepeaterCommandService? _commandService; - // Common commands for quick access late final List> _quickCommands = [ {'labelKey': 'advertise', 'command': 'advert'}, {'labelKey': 'getName', 'command': 'get name'}, @@ -67,12 +67,8 @@ class _RepeaterCliScreenState extends State { void _setupMessageListener() { final connector = Provider.of(context, listen: false); - - // Listen for incoming text messages from the repeater _frameSubscription = connector.receivedFrames.listen((frame) { if (frame.isEmpty) return; - - // Check if it's a text message response if (frame[0] == respCodeContactMsgRecv || frame[0] == respCodeContactMsgRecvV3) { _handleTextMessageResponse(frame); @@ -102,12 +98,7 @@ class _RepeaterCliScreenState extends State { final parsed = parseContactMessageText(frame); if (parsed == null) return; if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return; - - // Notify command service of response (for retry handling) _commandService?.handleResponse(widget.repeater, parsed.text); - - // Note: The command service will handle the response via the Future - // We don't need to add it to history here anymore as _sendCommand will do it } bool _matchesRepeaterPrefix(Uint8List prefix) { @@ -131,7 +122,6 @@ class _RepeaterCliScreenState extends State { }); }); - // Show debug info if requested if (showDebug && mounted) { final frame = buildSendCliCommandFrame( widget.repeater.publicKey, @@ -144,7 +134,6 @@ class _RepeaterCliScreenState extends State { ); } - // Send CLI command to repeater with retry try { if (_commandService != null) { final connector = Provider.of( @@ -157,7 +146,6 @@ class _RepeaterCliScreenState extends State { command, retries: 1, ); - if (mounted) { setState(() { _commandHistory.add({ @@ -184,7 +172,6 @@ class _RepeaterCliScreenState extends State { _historyIndex = -1; _commandFocusNode.requestFocus(); - // Auto-scroll to bottom Future.delayed(const Duration(milliseconds: 100), () { if (_scrollController.hasClients) { _scrollController.animateTo( @@ -239,36 +226,46 @@ class _RepeaterCliScreenState extends State { }); } + String _quickCommandLabel(String key) { + final l10n = context.l10n; + switch (key) { + case 'getName': + return l10n.repeater_cliQuickGetName; + case 'getRadio': + return l10n.repeater_cliQuickGetRadio; + case 'getTx': + return l10n.repeater_cliQuickGetTx; + case 'neighbors': + return l10n.repeater_cliQuickNeighbors; + case 'version': + return l10n.repeater_cliQuickVersion; + case 'advertise': + return l10n.repeater_cliQuickAdvertise; + case 'clock': + return l10n.repeater_cliQuickClock; + case 'clock sync': + return l10n.repeater_cliQuickClockSync; + case 'discovery': + return l10n.repeater_cliQuickDiscovery; + default: + return key; + } + } + @override Widget build(BuildContext context) { final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; final connector = context.watch(); final repeater = _resolveRepeater(connector); final isFloodMode = repeater.pathOverride == -1; return Scaffold( + backgroundColor: MeshPalette.bg, appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.repeater_cliTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - repeater.name, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.normal, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - centerTitle: false, + backgroundColor: MeshPalette.bg1, + title: Text(l10n.repeater_cliTitle), + centerTitle: true, actions: [ IconButton( icon: Icon(isFloodMode ? Icons.waves : Icons.route), @@ -317,93 +314,181 @@ class _RepeaterCliScreenState extends State { ), body: Column( children: [ - _buildQuickCommandsBar(), - const Divider(height: 1), + // Quick commands bar + Container( + color: MeshPalette.bg1, + padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _quickCommands.map((cmd) { + final label = _quickCommandLabel(cmd['labelKey']!); + return Padding( + padding: const EdgeInsets.only(right: 6), + child: ActionChip( + label: Text( + label, + style: MeshTheme.mono( + fontSize: 11, + fontWeight: FontWeight.w600, + color: MeshPalette.blue, + ), + ), + backgroundColor: MeshPalette.blueBg, + side: const BorderSide(color: MeshPalette.blueLine), + visualDensity: VisualDensity.compact, + onPressed: () => _useQuickCommand(cmd['command']!), + ), + ); + }).toList(), + ), + ), + ), + Divider(height: 1, color: MeshPalette.line), + + // Output area Expanded( child: _commandHistory.isEmpty ? _buildEmptyState() : _buildCommandHistory(), ), - const Divider(height: 1), - _buildCommandInput(), + + Divider(height: 1, color: MeshPalette.line), + + // Command input + Container( + color: MeshPalette.bg1, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + child: SafeArea( + child: Row( + children: [ + IconButton( + icon: Icon( + Icons.arrow_upward, + size: 18, + color: scheme.onSurfaceVariant, + ), + tooltip: l10n.repeater_previousCommand, + onPressed: () => _navigateHistory(true), + visualDensity: VisualDensity.compact, + ), + IconButton( + icon: Icon( + Icons.arrow_downward, + size: 18, + color: scheme.onSurfaceVariant, + ), + tooltip: l10n.repeater_nextCommand, + onPressed: () => _navigateHistory(false), + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 4), + Expanded( + child: TextField( + controller: _commandController, + focusNode: _commandFocusNode, + style: MeshTheme.mono( + fontSize: 13, + color: MeshPalette.ink, + ), + decoration: InputDecoration( + hintText: context.l10n.repeater_enterCommandHint, + hintStyle: MeshTheme.mono( + fontSize: 13, + color: MeshPalette.ink4, + ), + prefixText: '> ', + prefixStyle: MeshTheme.mono( + fontSize: 13, + color: MeshPalette.blue, + fontWeight: FontWeight.w700, + ), + filled: true, + fillColor: MeshPalette.bg2, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + borderSide: const BorderSide( + color: MeshPalette.line2, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + borderSide: const BorderSide( + color: MeshPalette.line2, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + borderSide: const BorderSide( + color: MeshPalette.blue, + width: 1.5, + ), + ), + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendCommand(), + ), + ), + const SizedBox(width: 6), + Material( + color: MeshPalette.blue.withValues(alpha: 0.15), + shape: const CircleBorder( + side: BorderSide(color: MeshPalette.blueLine), + ), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () { + HapticFeedback.lightImpact(); + _sendCommand(); + }, + child: const Padding( + padding: EdgeInsets.all(10), + child: Icon( + Icons.send, + size: 18, + color: MeshPalette.blue, + ), + ), + ), + ), + ], + ), + ), + ), ], ), ); } - Widget _buildQuickCommandsBar() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: _quickCommands.map((cmd) { - final label = _quickCommandLabel(cmd['labelKey']!); - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ActionChip( - label: Text(label), - onPressed: () => _useQuickCommand(cmd['command']!), - avatar: const Icon(Icons.play_arrow, size: 16), - ), - ); - }).toList(), - ), - ), - ); - } - - String _quickCommandLabel(String key) { - final l10n = context.l10n; - switch (key) { - case 'getName': - return l10n.repeater_cliQuickGetName; - case 'getRadio': - return l10n.repeater_cliQuickGetRadio; - case 'getTx': - return l10n.repeater_cliQuickGetTx; - case 'neighbors': - return l10n.repeater_cliQuickNeighbors; - case 'version': - return l10n.repeater_cliQuickVersion; - case 'advertise': - return l10n.repeater_cliQuickAdvertise; - case 'clock': - return l10n.repeater_cliQuickClock; - case 'clock sync': - return l10n.repeater_cliQuickClockSync; - case 'discovery': - return l10n.repeater_cliQuickDiscovery; - default: - return key; - } - } - Widget _buildEmptyState() { final l10n = context.l10n; return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.terminal, - size: 64, - color: Theme.of(context).colorScheme.onSurfaceVariant, + size: 48, + color: MeshPalette.ink4, ), - const SizedBox(height: 16), + const SizedBox(height: 12), Text( l10n.repeater_noCommandsSent, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, + style: MeshTheme.mono( + fontSize: 13, + color: MeshPalette.ink3, ), ), - const SizedBox(height: 8), + const SizedBox(height: 4), Text( l10n.repeater_typeCommandOrUseQuick, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, + style: const TextStyle( + fontSize: 12, + color: MeshPalette.ink4, ), ), ], @@ -414,49 +499,41 @@ class _RepeaterCliScreenState extends State { Widget _buildCommandHistory() { return ListView.builder( controller: _scrollController, - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: _commandHistory.length, itemBuilder: (context, index) { final entry = _commandHistory[index]; final isCommand = entry['type'] == 'command'; return Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.only(bottom: 2), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: isCommand - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Icon( - isCommand ? Icons.chevron_right : Icons.arrow_back, - size: 16, - color: isCommand - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.onSecondaryContainer, + // Gutter prefix + SizedBox( + width: 20, + child: Text( + isCommand ? '>' : ' ', + style: MeshTheme.mono( + fontSize: 12, + fontWeight: FontWeight.w700, + color: isCommand + ? MeshPalette.blue + : MeshPalette.ink3, + ), ), ), - const SizedBox(width: 12), + const SizedBox(width: 6), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SelectableText( - entry['text']!, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 13, - color: isCommand - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, - ), - ), - ], + child: SelectableText( + entry['text']!, + style: MeshTheme.mono( + fontSize: 12.5, + color: isCommand + ? MeshPalette.blue + : MeshPalette.ink, + ), ), ), ], @@ -466,54 +543,6 @@ class _RepeaterCliScreenState extends State { ); } - Widget _buildCommandInput() { - final l10n = context.l10n; - return Container( - padding: const EdgeInsets.all(12), - color: Theme.of(context).colorScheme.surface, - child: SafeArea( - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_upward, size: 20), - tooltip: l10n.repeater_previousCommand, - onPressed: () => _navigateHistory(true), - ), - IconButton( - icon: const Icon(Icons.arrow_downward, size: 20), - tooltip: l10n.repeater_nextCommand, - onPressed: () => _navigateHistory(false), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: _commandController, - focusNode: _commandFocusNode, - decoration: InputDecoration( - hintText: l10n.repeater_enterCommandHint, - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - prefixText: '> ', - ), - style: const TextStyle(fontFamily: 'monospace'), - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendCommand(), - ), - ), - const SizedBox(width: 8), - IconButton.filled( - icon: const Icon(Icons.send), - onPressed: _sendCommand, - ), - ], - ), - ), - ); - } - void _applyHelpCommand(String command) { _commandController.text = command; _commandController.selection = TextSelection.fromPosition( @@ -530,522 +559,156 @@ class _RepeaterCliScreenState extends State { void _showCommandHelp(BuildContext context) { final l10n = context.l10n; final generalCommands = [ - _CommandHelpEntry( - command: 'advert', - description: l10n.repeater_cliHelpAdvert, - ), - _CommandHelpEntry( - command: 'reboot', - description: l10n.repeater_cliHelpReboot, - ), - _CommandHelpEntry( - command: 'clock', - description: l10n.repeater_cliHelpClock, - ), - _CommandHelpEntry( - command: 'password {new-password}', - description: l10n.repeater_cliHelpPassword, - ), - _CommandHelpEntry( - command: 'ver', - description: l10n.repeater_cliHelpVersion, - ), - _CommandHelpEntry( - command: 'clear stats', - description: l10n.repeater_cliHelpClearStats, - ), - _CommandHelpEntry( - command: 'poweroff', - description: l10n.repeater_cliHelpPowerOff, - ), - _CommandHelpEntry( - command: 'shutdown', - description: l10n.repeater_cliHelpPowerOff, - ), - _CommandHelpEntry( - command: 'clkreboot', - description: l10n.repeater_cliHelpClkReboot, - ), - _CommandHelpEntry( - command: 'advert.zerohop', - description: l10n.repeater_cliHelpAdvertZeroHop, - ), - _CommandHelpEntry( - command: 'start ota', - description: l10n.repeater_cliHelpStartOta, - ), - _CommandHelpEntry( - command: 'time {epoch-seconds}', - description: l10n.repeater_cliHelpTime, - ), - _CommandHelpEntry( - command: 'board', - description: l10n.repeater_cliHelpBoard, - ), - _CommandHelpEntry( - command: 'discover.neighbors', - description: l10n.repeater_cliHelpDiscoverNeighbors, - ), - _CommandHelpEntry( - command: 'powersaving', - description: l10n.repeater_cliHelpPowersaving, - ), - _CommandHelpEntry( - command: 'powersaving {on|off}', - description: l10n.repeater_cliHelpPowersavingOnOff, - ), - _CommandHelpEntry( - command: 'erase', - description: l10n.repeater_cliHelpErase, - ), - _CommandHelpEntry( - command: 'stats-packets', - description: l10n.repeater_cliHelpStatsPackets, - ), - _CommandHelpEntry( - command: 'stats-radio', - description: l10n.repeater_cliHelpStatsRadio, - ), - _CommandHelpEntry( - command: 'stats-core', - description: l10n.repeater_cliHelpStatsCore, - ), + _CommandHelpEntry(command: 'advert', description: l10n.repeater_cliHelpAdvert), + _CommandHelpEntry(command: 'reboot', description: l10n.repeater_cliHelpReboot), + _CommandHelpEntry(command: 'clock', description: l10n.repeater_cliHelpClock), + _CommandHelpEntry(command: 'password {new-password}', description: l10n.repeater_cliHelpPassword), + _CommandHelpEntry(command: 'ver', description: l10n.repeater_cliHelpVersion), + _CommandHelpEntry(command: 'clear stats', description: l10n.repeater_cliHelpClearStats), + _CommandHelpEntry(command: 'poweroff', description: l10n.repeater_cliHelpPowerOff), + _CommandHelpEntry(command: 'shutdown', description: l10n.repeater_cliHelpPowerOff), + _CommandHelpEntry(command: 'clkreboot', description: l10n.repeater_cliHelpClkReboot), + _CommandHelpEntry(command: 'advert.zerohop', description: l10n.repeater_cliHelpAdvertZeroHop), + _CommandHelpEntry(command: 'start ota', description: l10n.repeater_cliHelpStartOta), + _CommandHelpEntry(command: 'time {epoch-seconds}', description: l10n.repeater_cliHelpTime), + _CommandHelpEntry(command: 'board', description: l10n.repeater_cliHelpBoard), + _CommandHelpEntry(command: 'discover.neighbors', description: l10n.repeater_cliHelpDiscoverNeighbors), + _CommandHelpEntry(command: 'powersaving', description: l10n.repeater_cliHelpPowersaving), + _CommandHelpEntry(command: 'powersaving {on|off}', description: l10n.repeater_cliHelpPowersavingOnOff), + _CommandHelpEntry(command: 'erase', description: l10n.repeater_cliHelpErase), + _CommandHelpEntry(command: 'stats-packets', description: l10n.repeater_cliHelpStatsPackets), + _CommandHelpEntry(command: 'stats-radio', description: l10n.repeater_cliHelpStatsRadio), + _CommandHelpEntry(command: 'stats-core', description: l10n.repeater_cliHelpStatsCore), ]; final settingsCommands = [ - _CommandHelpEntry( - command: 'set af {air-time-factor}', - description: l10n.repeater_cliHelpSetAf, - ), - _CommandHelpEntry( - command: 'set tx {tx-power-dbm}', - description: l10n.repeater_cliHelpSetTx, - ), - _CommandHelpEntry( - command: 'set repeat {on|off}', - description: l10n.repeater_cliHelpSetRepeat, - ), - _CommandHelpEntry( - command: 'set allow.read.only {on|off}', - description: l10n.repeater_cliHelpSetAllowReadOnly, - ), - _CommandHelpEntry( - command: 'set flood.max {max-hops}', - description: l10n.repeater_cliHelpSetFloodMax, - ), - _CommandHelpEntry( - command: 'set int.thresh {db}', - description: l10n.repeater_cliHelpSetIntThresh, - ), - _CommandHelpEntry( - command: 'set agc.reset.interval {seconds}', - description: l10n.repeater_cliHelpSetAgcResetInterval, - ), - _CommandHelpEntry( - command: 'set multi.acks {0|1}', - description: l10n.repeater_cliHelpSetMultiAcks, - ), - _CommandHelpEntry( - command: 'set advert.interval {minutes}', - description: l10n.repeater_cliHelpSetAdvertInterval, - ), - _CommandHelpEntry( - command: 'set flood.advert.interval {hours}', - description: l10n.repeater_cliHelpSetFloodAdvertInterval, - ), - _CommandHelpEntry( - command: 'set guest.password {guess-password}', - description: l10n.repeater_cliHelpSetGuestPassword, - ), - _CommandHelpEntry( - command: 'set name {name}', - description: l10n.repeater_cliHelpSetName, - ), - _CommandHelpEntry( - command: 'set lat {latitude}', - description: l10n.repeater_cliHelpSetLat, - ), - _CommandHelpEntry( - command: 'set lon {longitude}', - description: l10n.repeater_cliHelpSetLon, - ), - _CommandHelpEntry( - command: 'set radio {freq},{bw},{sf},{cr}', - description: l10n.repeater_cliHelpSetRadio, - ), - _CommandHelpEntry( - command: 'set rxdelay {base}', - description: l10n.repeater_cliHelpSetRxDelay, - ), - _CommandHelpEntry( - command: 'set txdelay {factor}', - description: l10n.repeater_cliHelpSetTxDelay, - ), - _CommandHelpEntry( - command: 'set direct.txdelay {factor}', - description: l10n.repeater_cliHelpSetDirectTxDelay, - ), - _CommandHelpEntry( - command: 'set bridge.enabled {on|off}', - description: l10n.repeater_cliHelpSetBridgeEnabled, - ), - _CommandHelpEntry( - command: 'set bridge.delay {0-10000}', - description: l10n.repeater_cliHelpSetBridgeDelay, - ), - _CommandHelpEntry( - command: 'set bridge.source {rx|tx}', - description: l10n.repeater_cliHelpSetBridgeSource, - ), - _CommandHelpEntry( - command: 'set bridge.baud {speed}', - description: l10n.repeater_cliHelpSetBridgeBaud, - ), - _CommandHelpEntry( - command: 'set bridge.secret {shared-secret}', - description: l10n.repeater_cliHelpSetBridgeSecret, - ), - _CommandHelpEntry( - command: 'set adc.multiplier {factor}', - description: l10n.repeater_cliHelpSetAdcMultiplier, - ), - _CommandHelpEntry( - command: 'tempradio {freq},{bw},{sf},{cr},{minutes}', - description: l10n.repeater_cliHelpTempRadio, - ), - _CommandHelpEntry( - command: 'setperm {pubkey-hex} {permissions}', - description: l10n.repeater_cliHelpSetPerm, - ), - _CommandHelpEntry( - command: 'set dutycycle {1-100}', - description: l10n.repeater_cliHelpSetDutyCycle, - ), - _CommandHelpEntry( - command: 'set prv.key {hex}', - description: l10n.repeater_cliHelpSetPrvKey, - ), - _CommandHelpEntry( - command: 'set radio.rxgain {on|off}', - description: l10n.repeater_cliHelpSetRadioRxGain, - ), - _CommandHelpEntry( - command: 'set owner.info {text}', - description: l10n.repeater_cliHelpSetOwnerInfo, - ), - _CommandHelpEntry( - command: 'set path.hash.mode {0|1|2}', - description: l10n.repeater_cliHelpSetPathHashMode, - ), - _CommandHelpEntry( - command: 'set loop.detect {off|minimal|moderate|strict}', - description: l10n.repeater_cliHelpSetLoopDetect, - ), - _CommandHelpEntry( - command: 'set freq {mhz}', - description: l10n.repeater_cliHelpSetFreq, - ), - _CommandHelpEntry( - command: 'set bridge.channel {1-14}', - description: l10n.repeater_cliHelpSetBridgeChannel, - ), + _CommandHelpEntry(command: 'set af {air-time-factor}', description: l10n.repeater_cliHelpSetAf), + _CommandHelpEntry(command: 'set tx {tx-power-dbm}', description: l10n.repeater_cliHelpSetTx), + _CommandHelpEntry(command: 'set repeat {on|off}', description: l10n.repeater_cliHelpSetRepeat), + _CommandHelpEntry(command: 'set allow.read.only {on|off}', description: l10n.repeater_cliHelpSetAllowReadOnly), + _CommandHelpEntry(command: 'set flood.max {max-hops}', description: l10n.repeater_cliHelpSetFloodMax), + _CommandHelpEntry(command: 'set int.thresh {db}', description: l10n.repeater_cliHelpSetIntThresh), + _CommandHelpEntry(command: 'set agc.reset.interval {seconds}', description: l10n.repeater_cliHelpSetAgcResetInterval), + _CommandHelpEntry(command: 'set multi.acks {0|1}', description: l10n.repeater_cliHelpSetMultiAcks), + _CommandHelpEntry(command: 'set advert.interval {minutes}', description: l10n.repeater_cliHelpSetAdvertInterval), + _CommandHelpEntry(command: 'set flood.advert.interval {hours}', description: l10n.repeater_cliHelpSetFloodAdvertInterval), + _CommandHelpEntry(command: 'set guest.password {guess-password}', description: l10n.repeater_cliHelpSetGuestPassword), + _CommandHelpEntry(command: 'set name {name}', description: l10n.repeater_cliHelpSetName), + _CommandHelpEntry(command: 'set lat {latitude}', description: l10n.repeater_cliHelpSetLat), + _CommandHelpEntry(command: 'set lon {longitude}', description: l10n.repeater_cliHelpSetLon), + _CommandHelpEntry(command: 'set radio {freq},{bw},{sf},{cr}', description: l10n.repeater_cliHelpSetRadio), + _CommandHelpEntry(command: 'set rxdelay {base}', description: l10n.repeater_cliHelpSetRxDelay), + _CommandHelpEntry(command: 'set txdelay {factor}', description: l10n.repeater_cliHelpSetTxDelay), + _CommandHelpEntry(command: 'set direct.txdelay {factor}', description: l10n.repeater_cliHelpSetDirectTxDelay), + _CommandHelpEntry(command: 'set bridge.enabled {on|off}', description: l10n.repeater_cliHelpSetBridgeEnabled), + _CommandHelpEntry(command: 'set bridge.delay {0-10000}', description: l10n.repeater_cliHelpSetBridgeDelay), + _CommandHelpEntry(command: 'set bridge.source {rx|tx}', description: l10n.repeater_cliHelpSetBridgeSource), + _CommandHelpEntry(command: 'set bridge.baud {speed}', description: l10n.repeater_cliHelpSetBridgeBaud), + _CommandHelpEntry(command: 'set bridge.secret {shared-secret}', description: l10n.repeater_cliHelpSetBridgeSecret), + _CommandHelpEntry(command: 'set adc.multiplier {factor}', description: l10n.repeater_cliHelpSetAdcMultiplier), + _CommandHelpEntry(command: 'tempradio {freq},{bw},{sf},{cr},{minutes}', description: l10n.repeater_cliHelpTempRadio), + _CommandHelpEntry(command: 'setperm {pubkey-hex} {permissions}', description: l10n.repeater_cliHelpSetPerm), + _CommandHelpEntry(command: 'set dutycycle {1-100}', description: l10n.repeater_cliHelpSetDutyCycle), + _CommandHelpEntry(command: 'set prv.key {hex}', description: l10n.repeater_cliHelpSetPrvKey), + _CommandHelpEntry(command: 'set radio.rxgain {on|off}', description: l10n.repeater_cliHelpSetRadioRxGain), + _CommandHelpEntry(command: 'set owner.info {text}', description: l10n.repeater_cliHelpSetOwnerInfo), + _CommandHelpEntry(command: 'set path.hash.mode {0|1|2}', description: l10n.repeater_cliHelpSetPathHashMode), + _CommandHelpEntry(command: 'set loop.detect {off|minimal|moderate|strict}', description: l10n.repeater_cliHelpSetLoopDetect), + _CommandHelpEntry(command: 'set freq {mhz}', description: l10n.repeater_cliHelpSetFreq), + _CommandHelpEntry(command: 'set bridge.channel {1-14}', description: l10n.repeater_cliHelpSetBridgeChannel), ]; final bridgeCommands = [ - _CommandHelpEntry( - command: 'get bridge.type', - description: l10n.repeater_cliHelpGetBridgeType, - ), + _CommandHelpEntry(command: 'get bridge.type', description: l10n.repeater_cliHelpGetBridgeType), ]; final loggingCommands = [ - _CommandHelpEntry( - command: 'log start', - description: l10n.repeater_cliHelpLogStart, - ), - _CommandHelpEntry( - command: 'log stop', - description: l10n.repeater_cliHelpLogStop, - ), - _CommandHelpEntry( - command: 'log erase', - description: l10n.repeater_cliHelpLogErase, - ), + _CommandHelpEntry(command: 'log start', description: l10n.repeater_cliHelpLogStart), + _CommandHelpEntry(command: 'log stop', description: l10n.repeater_cliHelpLogStop), + _CommandHelpEntry(command: 'log erase', description: l10n.repeater_cliHelpLogErase), ]; final neighborCommands = [ - _CommandHelpEntry( - command: 'neighbors', - description: l10n.repeater_cliHelpNeighbors, - ), - _CommandHelpEntry( - command: 'neighbor.remove {pubkey-prefix}', - description: l10n.repeater_cliHelpNeighborRemove, - ), + _CommandHelpEntry(command: 'neighbors', description: l10n.repeater_cliHelpNeighbors), + _CommandHelpEntry(command: 'neighbor.remove {pubkey-prefix}', description: l10n.repeater_cliHelpNeighborRemove), ]; final regionCommands = [ - _CommandHelpEntry( - command: 'region', - description: l10n.repeater_cliHelpRegion, - ), - _CommandHelpEntry( - command: 'region load', - description: l10n.repeater_cliHelpRegionLoad, - ), - _CommandHelpEntry( - command: 'region get {* | name-prefix}', - description: l10n.repeater_cliHelpRegionGet, - ), - _CommandHelpEntry( - command: 'region put {name} {* | parent-name-prefix}', - description: l10n.repeater_cliHelpRegionPut, - ), - _CommandHelpEntry( - command: 'region remove {name}', - description: l10n.repeater_cliHelpRegionRemove, - ), - _CommandHelpEntry( - command: 'region allowf {* | name-prefix}', - description: l10n.repeater_cliHelpRegionAllowf, - ), - _CommandHelpEntry( - command: 'region denyf {* | name-prefix}', - description: l10n.repeater_cliHelpRegionDenyf, - ), - _CommandHelpEntry( - command: 'region home', - description: l10n.repeater_cliHelpRegionHome, - ), - _CommandHelpEntry( - command: 'region home {* | name-prefix}', - description: l10n.repeater_cliHelpRegionHomeSet, - ), - _CommandHelpEntry( - command: 'region save', - description: l10n.repeater_cliHelpRegionSave, - ), - _CommandHelpEntry( - command: 'region default', - description: l10n.repeater_cliHelpRegionDefault, - ), - _CommandHelpEntry( - command: 'region default {* | name-prefix | }', - description: l10n.repeater_cliHelpRegionDefaultSet, - ), - _CommandHelpEntry( - command: 'region list allowed', - description: l10n.repeater_cliHelpRegionListAllowed, - ), - _CommandHelpEntry( - command: 'region list denied', - description: l10n.repeater_cliHelpRegionListDenied, - ), + _CommandHelpEntry(command: 'region', description: l10n.repeater_cliHelpRegion), + _CommandHelpEntry(command: 'region load', description: l10n.repeater_cliHelpRegionLoad), + _CommandHelpEntry(command: 'region get {* | name-prefix}', description: l10n.repeater_cliHelpRegionGet), + _CommandHelpEntry(command: 'region put {name} {* | parent-name-prefix}', description: l10n.repeater_cliHelpRegionPut), + _CommandHelpEntry(command: 'region remove {name}', description: l10n.repeater_cliHelpRegionRemove), + _CommandHelpEntry(command: 'region allowf {* | name-prefix}', description: l10n.repeater_cliHelpRegionAllowf), + _CommandHelpEntry(command: 'region denyf {* | name-prefix}', description: l10n.repeater_cliHelpRegionDenyf), + _CommandHelpEntry(command: 'region home', description: l10n.repeater_cliHelpRegionHome), + _CommandHelpEntry(command: 'region home {* | name-prefix}', description: l10n.repeater_cliHelpRegionHomeSet), + _CommandHelpEntry(command: 'region save', description: l10n.repeater_cliHelpRegionSave), + _CommandHelpEntry(command: 'region default', description: l10n.repeater_cliHelpRegionDefault), + _CommandHelpEntry(command: 'region default {* | name-prefix | }', description: l10n.repeater_cliHelpRegionDefaultSet), + _CommandHelpEntry(command: 'region list allowed', description: l10n.repeater_cliHelpRegionListAllowed), + _CommandHelpEntry(command: 'region list denied', description: l10n.repeater_cliHelpRegionListDenied), ]; final getCommands = [ - _CommandHelpEntry( - command: 'get name', - description: l10n.repeater_cliHelpGetName, - ), - _CommandHelpEntry( - command: 'get role', - description: l10n.repeater_cliHelpGetRole, - ), - _CommandHelpEntry( - command: 'get public.key', - description: l10n.repeater_cliHelpGetPublicKey, - ), - _CommandHelpEntry( - command: 'get prv.key', - description: l10n.repeater_cliHelpGetPrvKey, - ), - _CommandHelpEntry( - command: 'get repeat', - description: l10n.repeater_cliHelpGetRepeat, - ), - _CommandHelpEntry( - command: 'get tx', - description: l10n.repeater_cliHelpGetTx, - ), - _CommandHelpEntry( - command: 'get freq', - description: l10n.repeater_cliHelpGetFreq, - ), - _CommandHelpEntry( - command: 'get radio', - description: l10n.repeater_cliHelpGetRadio, - ), - _CommandHelpEntry( - command: 'get radio.rxgain', - description: l10n.repeater_cliHelpGetRadioRxGain, - ), - _CommandHelpEntry( - command: 'get af', - description: l10n.repeater_cliHelpGetAf, - ), - _CommandHelpEntry( - command: 'get dutycycle', - description: l10n.repeater_cliHelpGetDutyCycle, - ), - _CommandHelpEntry( - command: 'get int.thresh', - description: l10n.repeater_cliHelpGetIntThresh, - ), - _CommandHelpEntry( - command: 'get agc.reset.interval', - description: l10n.repeater_cliHelpGetAgcResetInterval, - ), - _CommandHelpEntry( - command: 'get multi.acks', - description: l10n.repeater_cliHelpGetMultiAcks, - ), - _CommandHelpEntry( - command: 'get allow.read.only', - description: l10n.repeater_cliHelpGetAllowReadOnly, - ), - _CommandHelpEntry( - command: 'get advert.interval', - description: l10n.repeater_cliHelpGetAdvertInterval, - ), - _CommandHelpEntry( - command: 'get flood.advert.interval', - description: l10n.repeater_cliHelpGetFloodAdvertInterval, - ), - _CommandHelpEntry( - command: 'get guest.password', - description: l10n.repeater_cliHelpGetGuestPassword, - ), - _CommandHelpEntry( - command: 'get lat', - description: l10n.repeater_cliHelpGetLat, - ), - _CommandHelpEntry( - command: 'get lon', - description: l10n.repeater_cliHelpGetLon, - ), - _CommandHelpEntry( - command: 'get rxdelay', - description: l10n.repeater_cliHelpGetRxDelay, - ), - _CommandHelpEntry( - command: 'get txdelay', - description: l10n.repeater_cliHelpGetTxDelay, - ), - _CommandHelpEntry( - command: 'get direct.txdelay', - description: l10n.repeater_cliHelpGetDirectTxDelay, - ), - _CommandHelpEntry( - command: 'get flood.max', - description: l10n.repeater_cliHelpGetFloodMax, - ), - _CommandHelpEntry( - command: 'get owner.info', - description: l10n.repeater_cliHelpGetOwnerInfo, - ), - _CommandHelpEntry( - command: 'get path.hash.mode', - description: l10n.repeater_cliHelpGetPathHashMode, - ), - _CommandHelpEntry( - command: 'get loop.detect', - description: l10n.repeater_cliHelpGetLoopDetect, - ), - _CommandHelpEntry( - command: 'get acl', - description: l10n.repeater_cliHelpGetAcl, - ), - _CommandHelpEntry( - command: 'get bridge.enabled', - description: l10n.repeater_cliHelpGetBridgeEnabled, - ), - _CommandHelpEntry( - command: 'get bridge.delay', - description: l10n.repeater_cliHelpGetBridgeDelay, - ), - _CommandHelpEntry( - command: 'get bridge.source', - description: l10n.repeater_cliHelpGetBridgeSource, - ), - _CommandHelpEntry( - command: 'get bridge.baud', - description: l10n.repeater_cliHelpGetBridgeBaud, - ), - _CommandHelpEntry( - command: 'get bridge.channel', - description: l10n.repeater_cliHelpGetBridgeChannel, - ), - _CommandHelpEntry( - command: 'get bridge.secret', - description: l10n.repeater_cliHelpGetBridgeSecret, - ), - _CommandHelpEntry( - command: 'get bootloader.ver', - description: l10n.repeater_cliHelpGetBootloaderVer, - ), - _CommandHelpEntry( - command: 'get adc.multiplier', - description: l10n.repeater_cliHelpGetAdcMultiplier, - ), + _CommandHelpEntry(command: 'get name', description: l10n.repeater_cliHelpGetName), + _CommandHelpEntry(command: 'get role', description: l10n.repeater_cliHelpGetRole), + _CommandHelpEntry(command: 'get public.key', description: l10n.repeater_cliHelpGetPublicKey), + _CommandHelpEntry(command: 'get prv.key', description: l10n.repeater_cliHelpGetPrvKey), + _CommandHelpEntry(command: 'get repeat', description: l10n.repeater_cliHelpGetRepeat), + _CommandHelpEntry(command: 'get tx', description: l10n.repeater_cliHelpGetTx), + _CommandHelpEntry(command: 'get freq', description: l10n.repeater_cliHelpGetFreq), + _CommandHelpEntry(command: 'get radio', description: l10n.repeater_cliHelpGetRadio), + _CommandHelpEntry(command: 'get radio.rxgain', description: l10n.repeater_cliHelpGetRadioRxGain), + _CommandHelpEntry(command: 'get af', description: l10n.repeater_cliHelpGetAf), + _CommandHelpEntry(command: 'get dutycycle', description: l10n.repeater_cliHelpGetDutyCycle), + _CommandHelpEntry(command: 'get int.thresh', description: l10n.repeater_cliHelpGetIntThresh), + _CommandHelpEntry(command: 'get agc.reset.interval', description: l10n.repeater_cliHelpGetAgcResetInterval), + _CommandHelpEntry(command: 'get multi.acks', description: l10n.repeater_cliHelpGetMultiAcks), + _CommandHelpEntry(command: 'get allow.read.only', description: l10n.repeater_cliHelpGetAllowReadOnly), + _CommandHelpEntry(command: 'get advert.interval', description: l10n.repeater_cliHelpGetAdvertInterval), + _CommandHelpEntry(command: 'get flood.advert.interval', description: l10n.repeater_cliHelpGetFloodAdvertInterval), + _CommandHelpEntry(command: 'get guest.password', description: l10n.repeater_cliHelpGetGuestPassword), + _CommandHelpEntry(command: 'get lat', description: l10n.repeater_cliHelpGetLat), + _CommandHelpEntry(command: 'get lon', description: l10n.repeater_cliHelpGetLon), + _CommandHelpEntry(command: 'get rxdelay', description: l10n.repeater_cliHelpGetRxDelay), + _CommandHelpEntry(command: 'get txdelay', description: l10n.repeater_cliHelpGetTxDelay), + _CommandHelpEntry(command: 'get direct.txdelay', description: l10n.repeater_cliHelpGetDirectTxDelay), + _CommandHelpEntry(command: 'get flood.max', description: l10n.repeater_cliHelpGetFloodMax), + _CommandHelpEntry(command: 'get owner.info', description: l10n.repeater_cliHelpGetOwnerInfo), + _CommandHelpEntry(command: 'get path.hash.mode', description: l10n.repeater_cliHelpGetPathHashMode), + _CommandHelpEntry(command: 'get loop.detect', description: l10n.repeater_cliHelpGetLoopDetect), + _CommandHelpEntry(command: 'get acl', description: l10n.repeater_cliHelpGetAcl), + _CommandHelpEntry(command: 'get bridge.enabled', description: l10n.repeater_cliHelpGetBridgeEnabled), + _CommandHelpEntry(command: 'get bridge.delay', description: l10n.repeater_cliHelpGetBridgeDelay), + _CommandHelpEntry(command: 'get bridge.source', description: l10n.repeater_cliHelpGetBridgeSource), + _CommandHelpEntry(command: 'get bridge.baud', description: l10n.repeater_cliHelpGetBridgeBaud), + _CommandHelpEntry(command: 'get bridge.channel', description: l10n.repeater_cliHelpGetBridgeChannel), + _CommandHelpEntry(command: 'get bridge.secret', description: l10n.repeater_cliHelpGetBridgeSecret), + _CommandHelpEntry(command: 'get bootloader.ver', description: l10n.repeater_cliHelpGetBootloaderVer), + _CommandHelpEntry(command: 'get adc.multiplier', description: l10n.repeater_cliHelpGetAdcMultiplier), ]; final powerMgmtCommands = [ - _CommandHelpEntry( - command: 'get pwrmgt.support', - description: l10n.repeater_cliHelpGetPwrMgtSupport, - ), - _CommandHelpEntry( - command: 'get pwrmgt.source', - description: l10n.repeater_cliHelpGetPwrMgtSource, - ), - _CommandHelpEntry( - command: 'get pwrmgt.bootreason', - description: l10n.repeater_cliHelpGetPwrMgtBootReason, - ), - _CommandHelpEntry( - command: 'get pwrmgt.bootmv', - description: l10n.repeater_cliHelpGetPwrMgtBootMv, - ), + _CommandHelpEntry(command: 'get pwrmgt.support', description: l10n.repeater_cliHelpGetPwrMgtSupport), + _CommandHelpEntry(command: 'get pwrmgt.source', description: l10n.repeater_cliHelpGetPwrMgtSource), + _CommandHelpEntry(command: 'get pwrmgt.bootreason', description: l10n.repeater_cliHelpGetPwrMgtBootReason), + _CommandHelpEntry(command: 'get pwrmgt.bootmv', description: l10n.repeater_cliHelpGetPwrMgtBootMv), ]; final sensorCommands = [ - _CommandHelpEntry( - command: 'sensor get {key}', - description: l10n.repeater_cliHelpSensorGet, - ), - _CommandHelpEntry( - command: 'sensor set {key} {value}', - description: l10n.repeater_cliHelpSensorSet, - ), - _CommandHelpEntry( - command: 'sensor list [start]', - description: l10n.repeater_cliHelpSensorList, - ), + _CommandHelpEntry(command: 'sensor get {key}', description: l10n.repeater_cliHelpSensorGet), + _CommandHelpEntry(command: 'sensor set {key} {value}', description: l10n.repeater_cliHelpSensorSet), + _CommandHelpEntry(command: 'sensor list [start]', description: l10n.repeater_cliHelpSensorList), ]; final gpsCommands = [ _CommandHelpEntry(command: 'gps', description: l10n.repeater_cliHelpGps), - _CommandHelpEntry( - command: 'gps {on|off}', - description: l10n.repeater_cliHelpGpsOnOff, - ), - _CommandHelpEntry( - command: 'gps sync', - description: l10n.repeater_cliHelpGpsSync, - ), - _CommandHelpEntry( - command: 'gps setloc', - description: l10n.repeater_cliHelpGpsSetLoc, - ), - _CommandHelpEntry( - command: 'gps advert', - description: l10n.repeater_cliHelpGpsAdvert, - ), - _CommandHelpEntry( - command: 'gps advert {none|share|prefs}', - description: l10n.repeater_cliHelpGpsAdvertSet, - ), + _CommandHelpEntry(command: 'gps {on|off}', description: l10n.repeater_cliHelpGpsOnOff), + _CommandHelpEntry(command: 'gps sync', description: l10n.repeater_cliHelpGpsSync), + _CommandHelpEntry(command: 'gps setloc', description: l10n.repeater_cliHelpGpsSetLoc), + _CommandHelpEntry(command: 'gps advert', description: l10n.repeater_cliHelpGpsAdvert), + _CommandHelpEntry(command: 'gps advert {none|share|prefs}', description: l10n.repeater_cliHelpGpsAdvertSet), ]; showDialog( @@ -1057,64 +720,27 @@ class _RepeaterCliScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - l10n.repeater_commandsListNote, - style: const TextStyle(fontSize: 13), - ), + Text(l10n.repeater_commandsListNote, style: const TextStyle(fontSize: 13)), const SizedBox(height: 16), - _buildHelpSection( - context, - l10n.repeater_general, - generalCommands, - ), + _buildHelpSection(context, l10n.repeater_general, generalCommands), const SizedBox(height: 16), - _buildHelpSection( - context, - l10n.repeater_getCategory, - getCommands, - ), + _buildHelpSection(context, l10n.repeater_getCategory, getCommands), const SizedBox(height: 16), - _buildHelpSection( - context, - l10n.repeater_settingsCategory, - settingsCommands, - ), + _buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands), const SizedBox(height: 16), - _buildHelpSection( - context, - l10n.repeater_powerMgmt, - powerMgmtCommands, - ), + _buildHelpSection(context, l10n.repeater_powerMgmt, powerMgmtCommands), const SizedBox(height: 16), _buildHelpSection(context, l10n.repeater_sensors, sensorCommands), const SizedBox(height: 16), _buildHelpSection(context, l10n.repeater_bridge, bridgeCommands), const SizedBox(height: 16), - _buildHelpSection( - context, - l10n.repeater_logging, - loggingCommands, - ), + _buildHelpSection(context, l10n.repeater_logging, loggingCommands), const SizedBox(height: 16), - _buildHelpSection( - context, - l10n.repeater_neighborsRepeaterOnly, - neighborCommands, - ), + _buildHelpSection(context, l10n.repeater_neighborsRepeaterOnly, neighborCommands), const SizedBox(height: 16), - _buildHelpSection( - context, - l10n.repeater_regionManagementRepeaterOnly, - regionCommands, - note: l10n.repeater_regionNote, - ), + _buildHelpSection(context, l10n.repeater_regionManagementRepeaterOnly, regionCommands, note: l10n.repeater_regionNote), const SizedBox(height: 16), - _buildHelpSection( - context, - l10n.repeater_gpsManagement, - gpsCommands, - note: l10n.repeater_gpsNote, - ), + _buildHelpSection(context, l10n.repeater_gpsManagement, gpsCommands, note: l10n.repeater_gpsNote), ], ), ), @@ -1134,16 +760,14 @@ class _RepeaterCliScreenState extends State { List<_CommandHelpEntry> commands, { String? note, }) { + final scheme = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), if (note != null) ...[ - const SizedBox(height: 6), - Text(note, style: const TextStyle(fontSize: 12)), + const SizedBox(height: 4), + Text(note, style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant)), ], const SizedBox(height: 8), ...commands.map((entry) => _buildHelpCommandCard(context, entry)), @@ -1152,39 +776,35 @@ class _RepeaterCliScreenState extends State { } Widget _buildHelpCommandCard(BuildContext context, _CommandHelpEntry entry) { - final colorScheme = Theme.of(context).colorScheme; + final scheme = Theme.of(context).colorScheme; return Card( elevation: 0, - margin: const EdgeInsets.only(bottom: 8), - color: colorScheme.surfaceContainerHighest, + margin: const EdgeInsets.only(bottom: 6), + color: scheme.surfaceContainerHighest, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(MeshRadii.sm), + side: BorderSide(color: scheme.outlineVariant), ), child: InkWell( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(MeshRadii.sm), onTap: () => _applyHelpCommand(entry.command), child: Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( entry.command, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 13, - fontWeight: FontWeight.bold, - color: colorScheme.onSurfaceVariant, + style: MeshTheme.mono( + fontSize: 12, + fontWeight: FontWeight.w600, + color: MeshPalette.blue, ), ), - const SizedBox(height: 6), + const SizedBox(height: 4), Text( entry.description, - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - ), + style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant), ), ], ), @@ -1197,6 +817,5 @@ class _RepeaterCliScreenState extends State { class _CommandHelpEntry { final String command; final String description; - const _CommandHelpEntry({required this.command, required this.description}); } diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index 2aebcf03..bbdec602 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; import '../l10n/contact_localization.dart'; import '../services/app_settings_service.dart'; +import '../theme/mesh_theme.dart'; +import '../widgets/mesh_ui.dart'; import 'repeater_status_screen.dart'; import 'repeater_cli_screen.dart'; import 'repeater_settings_screen.dart'; @@ -26,189 +29,157 @@ class RepeaterHubScreen extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; final settingsService = context.watch(); final chemistry = settingsService.batteryChemistryForRepeater( repeater.publicKeyHex, ); + return Scaffold( appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (isAdmin) - Text( - repeater.type == advTypeRepeater - ? l10n.repeater_management - : l10n.room_management, - ), - if (!isAdmin) - Text( - repeater.type == advTypeRepeater - ? l10n.repeater_guest - : l10n.room_guest, - ), - Text( - repeater.name, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.normal, - ), - ), - ], + title: Text( + repeater.type == advTypeRepeater + ? (isAdmin ? l10n.repeater_management : l10n.repeater_guest) + : (isAdmin ? l10n.room_management : l10n.room_guest), ), - centerTitle: false, + centerTitle: true, ), body: SafeArea( top: false, child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.only(bottom: 24), children: [ - // Repeater info card - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( + // ── Identity card ───────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 4), + child: MeshCard( + margin: EdgeInsets.zero, + padding: const EdgeInsets.all(20), + child: Row( children: [ - CircleAvatar( - radius: 40, - backgroundColor: Theme.of( - context, - ).colorScheme.tertiaryContainer, - child: Icon( - Icons.cell_tower, - size: 40, - color: Theme.of( - context, - ).colorScheme.onTertiaryContainer, - ), + AvatarCircle( + name: repeater.name, + size: 52, + color: MeshPalette.warn, + icon: Icons.cell_tower, ), - const SizedBox(height: 16), - Text( - repeater.name, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - repeater.shortPubKeyHex, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Text( - repeater.pathLabel(context.l10n), - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - if (repeater.hasLocation) ...[ - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.center, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.location_on, - size: 14, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), Text( - '${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}', - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + repeater.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + repeater.shortPubKeyHex, + style: MeshTheme.mono( + fontSize: 11, + color: scheme.onSurfaceVariant, ), ), + const SizedBox(height: 4), + Text( + repeater.pathLabel(l10n), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: scheme.onSurfaceVariant), + ), + if (repeater.hasLocation) ...[ + const SizedBox(height: 2), + Row( + children: [ + Icon( + Icons.location_on, + size: 12, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 3), + Expanded( + child: Text( + '${repeater.latitude?.toStringAsFixed(4)}, ' + '${repeater.longitude?.toStringAsFixed(4)}', + style: MeshTheme.mono( + fontSize: 10, + color: scheme.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], ], ), - ], + ), + StatusChip( + label: isAdmin ? 'ADMIN' : 'GUEST', + color: isAdmin ? MeshPalette.blue : scheme.onSurfaceVariant, + ), ], ), ), ), - const SizedBox(height: 24), - if (isAdmin) - Card( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.battery_full), - const SizedBox(width: 10), - Expanded( - child: Text( - l10n.appSettings_batteryChemistry, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: chemistry, - isExpanded: true, - decoration: const InputDecoration( - border: UnderlineInputBorder(), - isDense: true, - ), - onChanged: (value) { - if (value == null) return; - settingsService.setBatteryChemistryForRepeater( - repeater.publicKeyHex, - value, - ); - }, - items: [ - DropdownMenuItem( - value: 'nmc', - child: Text(l10n.appSettings_batteryNmc), - ), - DropdownMenuItem( - value: 'lifepo4', - child: Text(l10n.appSettings_batteryLifepo4), - ), - DropdownMenuItem( - value: 'lipo', - child: Text(l10n.appSettings_batteryLipo), - ), - ], - ), - ], + + // ── Battery chemistry (admin only) ───────────────────────────── + if (isAdmin) ...[ + SectionHeader(l10n.appSettings_batteryChemistry), + MeshCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.fromLTRB(14, 10, 14, 14), + child: DropdownButtonFormField( + initialValue: chemistry, + isExpanded: true, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.battery_full, size: 18), + labelText: l10n.appSettings_batteryChemistry, ), + onChanged: (value) { + if (value == null) return; + settingsService.setBatteryChemistryForRepeater( + repeater.publicKeyHex, + value, + ); + }, + items: [ + DropdownMenuItem( + value: 'nmc', + child: Text(l10n.appSettings_batteryNmc), + ), + DropdownMenuItem( + value: 'lifepo4', + child: Text(l10n.appSettings_batteryLifepo4), + ), + DropdownMenuItem( + value: 'lipo', + child: Text(l10n.appSettings_batteryLipo), + ), + ], ), ), - const SizedBox(height: 24), - Text( - isAdmin - ? l10n.repeater_managementTools - : l10n.repeater_guestTools, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ], + + // ── Tools ────────────────────────────────────────────────────── + SectionHeader( + isAdmin ? l10n.repeater_managementTools : l10n.repeater_guestTools, ), - const SizedBox(height: 16), - // Status button - _buildManagementCard( - context, + + _HubActionTile( + index: 0, icon: Icons.analytics, title: l10n.repeater_status, subtitle: l10n.repeater_statusSubtitle, - color: Theme.of(context).colorScheme.primary, + accentColor: MeshPalette.blue, onTap: () { + HapticFeedback.selectionClick(); Navigator.push( context, MaterialPageRoute( @@ -220,15 +191,15 @@ class RepeaterHubScreen extends StatelessWidget { ); }, ), - const SizedBox(height: 16), - // Telemetry button - _buildManagementCard( - context, + + _HubActionTile( + index: 1, icon: Icons.bar_chart_sharp, title: l10n.repeater_telemetry, subtitle: l10n.repeater_telemetrySubtitle, - color: Theme.of(context).colorScheme.secondary, + accentColor: MeshPalette.magenta, onTap: () { + HapticFeedback.selectionClick(); Navigator.push( context, MaterialPageRoute( @@ -237,16 +208,34 @@ class RepeaterHubScreen extends StatelessWidget { ); }, ), - if (isAdmin) const SizedBox(height: 12), - // CLI button - if (isAdmin) - _buildManagementCard( - context, + + _HubActionTile( + index: 2, + icon: Icons.group, + title: l10n.repeater_neighbors, + subtitle: l10n.repeater_neighborsSubtitle, + accentColor: MeshPalette.signal, + onTap: () { + HapticFeedback.selectionClick(); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + NeighborsScreen(repeater: repeater, password: password), + ), + ); + }, + ), + + if (isAdmin) ...[ + _HubActionTile( + index: 3, icon: Icons.terminal, title: l10n.repeater_cli, subtitle: l10n.repeater_cliSubtitle, - color: Theme.of(context).colorScheme.tertiary, + accentColor: MeshPalette.warn, onTap: () { + HapticFeedback.selectionClick(); Navigator.push( context, MaterialPageRoute( @@ -258,34 +247,14 @@ class RepeaterHubScreen extends StatelessWidget { ); }, ), - const SizedBox(height: 12), - // Neighbors button - _buildManagementCard( - context, - icon: Icons.group, - title: l10n.repeater_neighbors, - subtitle: l10n.repeater_neighborsSubtitle, - color: Theme.of(context).colorScheme.tertiary, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - NeighborsScreen(repeater: repeater, password: password), - ), - ); - }, - ), - if (isAdmin) const SizedBox(height: 12), - // Settings button - if (isAdmin) - _buildManagementCard( - context, + _HubActionTile( + index: 4, icon: Icons.settings, title: l10n.repeater_settings, subtitle: l10n.repeater_settingsSubtitle, - color: Theme.of(context).colorScheme.error, + accentColor: MeshPalette.alert, onTap: () { + HapticFeedback.selectionClick(); Navigator.push( context, MaterialPageRoute( @@ -297,66 +266,82 @@ class RepeaterHubScreen extends StatelessWidget { ); }, ), + ], ], ), ), ); } +} - Widget _buildManagementCard( - BuildContext context, { - required IconData icon, - required String title, - required String subtitle, - required Color color, - required VoidCallback onTap, - }) { - return Card( - elevation: 2, - child: InkWell( +class _HubActionTile extends StatelessWidget { + final int index; + final IconData icon; + final String title; + final String subtitle; + final Color accentColor; + final VoidCallback onTap; + + const _HubActionTile({ + required this.index, + required this.icon, + required this.title, + required this.subtitle, + required this.accentColor, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return ListEntrance( + index: index, + child: MeshCard( onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: color, size: 32), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(MeshRadii.md), + border: Border.all( + color: accentColor.withValues(alpha: 0.3), ), ), - Icon( - Icons.chevron_right, - color: Theme.of(context).colorScheme.onSurfaceVariant, + alignment: Alignment.center, + child: Icon(icon, size: 22, color: accentColor), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + fontSize: 12.5, + color: scheme.onSurfaceVariant, + ), + ), + ], ), - ], - ), + ), + Icon( + Icons.chevron_right, + color: scheme.onSurfaceVariant, + size: 20, + ), + ], ), ), ); diff --git a/lib/screens/repeater_settings_screen.dart b/lib/screens/repeater_settings_screen.dart index e04cfb70..30675e58 100644 --- a/lib/screens/repeater_settings_screen.dart +++ b/lib/screens/repeater_settings_screen.dart @@ -8,6 +8,8 @@ import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../services/repeater_command_service.dart'; import '../services/storage_service.dart'; +import '../theme/mesh_theme.dart'; +import '../widgets/mesh_ui.dart'; import '../widgets/routing_sheet.dart'; import '../helpers/snack_bar_builder.dart'; @@ -1003,39 +1005,6 @@ class _RepeaterSettingsScreenState extends State { } } - Widget _buildSectionHeader({ - required IconData icon, - required String title, - String? tooltip, - bool isRefreshing = false, - VoidCallback? onRefresh, - }) { - return Row( - children: [ - Icon(icon, color: Theme.of(context).textTheme.headlineSmall?.color), - const SizedBox(width: 8), - Text( - title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - if (onRefresh != null) ...[ - const Spacer(), - IconButton( - icon: isRefreshing - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh), - onPressed: isRefreshing ? null : onRefresh, - tooltip: tooltip, - ), - ], - ], - ); - } - Widget _buildInlineRefreshButton({ required bool isRefreshing, required VoidCallback onRefresh, @@ -1067,21 +1036,8 @@ class _RepeaterSettingsScreenState extends State { return Scaffold( appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.repeater_settingsTitle), - Text( - repeater.name, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.normal, - ), - ), - ], - ), - centerTitle: false, + title: Text(l10n.repeater_settingsTitle), + centerTitle: true, actions: [ IconButton( icon: Icon(isFloodMode ? Icons.waves : Icons.route), @@ -1102,27 +1058,17 @@ class _RepeaterSettingsScreenState extends State { child: _isLoading && _nameController.text.isEmpty ? const Center(child: CircularProgressIndicator()) : ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.only(bottom: 32), children: [ _buildBasicSettingsCard(), - const SizedBox(height: 16), _buildRadioSettingsCard(), - const SizedBox(height: 16), _buildLocationSettingsCard(), - const SizedBox(height: 16), _buildFeatureTogglesCard(), - const SizedBox(height: 16), _buildNetworkHealthCard(), - const SizedBox(height: 16), _buildAdvertisementSettingsCard(), - const SizedBox(height: 16), _buildOwnerInfoCard(), - const SizedBox(height: 16), _buildActionsCard(), - const SizedBox(height: 16), _buildAdvancedCard(), - const SizedBox(height: 32), - const Divider(), const SizedBox(height: 16), _buildDangerZoneCard(), ], @@ -1133,363 +1079,350 @@ class _RepeaterSettingsScreenState extends State { Widget _buildBasicSettingsCard() { final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader( - icon: Icons.settings, - title: l10n.repeater_basicSettings, - tooltip: l10n.repeater_refreshBasicSettings, - isRefreshing: _refreshingBasic, - onRefresh: _refreshBasicSettings, - ), - const Divider(), - TextField( - controller: _nameController, - decoration: InputDecoration( - labelText: l10n.repeater_repeaterName, - helperText: l10n.repeater_repeaterNameHelper, - border: const OutlineInputBorder(), + final refreshButton = _refreshingBasic + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: _refreshBasicSettings, + tooltip: l10n.repeater_refreshBasicSettings, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(l10n.repeater_basicSettings, trailing: refreshButton), + MeshCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.repeater_repeaterName, + helperText: l10n.repeater_repeaterNameHelper, + ), + onChanged: (_) => _markChanged(_SettingField.name), ), - onChanged: (_) => _markChanged(_SettingField.name), - ), - const SizedBox(height: 16), - TextField( - controller: _passwordController, - decoration: InputDecoration( - labelText: l10n.repeater_adminPassword, - helperText: l10n.repeater_adminPasswordHelper, - border: const OutlineInputBorder(), + const SizedBox(height: 12), + TextField( + controller: _passwordController, + decoration: InputDecoration( + labelText: l10n.repeater_adminPassword, + helperText: l10n.repeater_adminPasswordHelper, + ), + obscureText: true, + onChanged: (_) => _flagHasChanges(), ), - obscureText: true, - onChanged: (_) => _flagHasChanges(), - ), - const SizedBox(height: 16), - TextField( - controller: _guestPasswordController, - decoration: InputDecoration( - labelText: l10n.repeater_guestPassword, - helperText: l10n.repeater_guestPasswordHelper, - border: const OutlineInputBorder(), + const SizedBox(height: 12), + TextField( + controller: _guestPasswordController, + decoration: InputDecoration( + labelText: l10n.repeater_guestPassword, + helperText: l10n.repeater_guestPasswordHelper, + ), + obscureText: true, + onChanged: (_) => _flagHasChanges(), ), - obscureText: true, - onChanged: (_) => _flagHasChanges(), - ), - ], + ], + ), ), - ), + ], ); } Widget _buildRadioSettingsCard() { final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader( - icon: Icons.radio, - title: l10n.repeater_radioSettings, - tooltip: l10n.repeater_refreshRadioSettings, - isRefreshing: _refreshingRadio, - onRefresh: _refreshRadioSettings, - ), - const Divider(), - TextField( - controller: _freqController, - decoration: InputDecoration( - labelText: l10n.repeater_frequencyMhz, - helperText: l10n.repeater_frequencyHelper, - border: const OutlineInputBorder(), - suffixText: 'MHz', + final refreshButton = _refreshingRadio + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: _refreshRadioSettings, + tooltip: l10n.repeater_refreshRadioSettings, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(l10n.repeater_radioSettings, trailing: refreshButton), + MeshCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _freqController, + decoration: InputDecoration( + labelText: l10n.repeater_frequencyMhz, + helperText: l10n.repeater_frequencyHelper, + suffixText: 'MHz', + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + onChanged: (_) => _markChanged(_SettingField.radio), ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - onChanged: (_) => _markChanged(_SettingField.radio), - ), - const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextField( - controller: _txPowerController, - decoration: InputDecoration( - labelText: l10n.repeater_txPower, - helperText: l10n.repeater_txPowerHelper, - border: const OutlineInputBorder(), - suffixText: 'dBm', + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _txPowerController, + decoration: InputDecoration( + labelText: l10n.repeater_txPower, + helperText: l10n.repeater_txPowerHelper, + suffixText: 'dBm', + ), + keyboardType: TextInputType.number, + onChanged: (_) => _markChanged(_SettingField.txPower), ), - keyboardType: TextInputType.number, - onChanged: (_) => _markChanged(_SettingField.txPower), ), + _buildInlineRefreshButton( + isRefreshing: _refreshingTxPower, + onRefresh: _refreshTxPower, + tooltip: l10n.repeater_refreshTxPower, + ), + ], + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _bandwidth, + decoration: InputDecoration( + labelText: l10n.repeater_bandwidth, ), - const SizedBox(width: 8), - _buildInlineRefreshButton( - isRefreshing: _refreshingTxPower, - onRefresh: _refreshTxPower, - tooltip: l10n.repeater_refreshTxPower, + items: _bandwidthOptions.map((bw) { + return DropdownMenuItem( + value: bw, + child: Text(_formatBandwidthLabel(bw)), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _bandwidth = value; + }); + _markChanged(_SettingField.radio); + } + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _spreadingFactor, + decoration: InputDecoration( + labelText: l10n.repeater_spreadingFactor, ), - ], - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _bandwidth, - decoration: InputDecoration( - labelText: l10n.repeater_bandwidth, - border: const OutlineInputBorder(), + items: _spreadingFactorOptions.map((sf) { + return DropdownMenuItem(value: sf, child: Text('SF$sf')); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _spreadingFactor = value; + }); + _markChanged(_SettingField.radio); + } + }, ), - items: _bandwidthOptions.map((bw) { - return DropdownMenuItem( - value: bw, - child: Text(_formatBandwidthLabel(bw)), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _bandwidth = value; - }); - _markChanged(_SettingField.radio); - } - }, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _spreadingFactor, - decoration: InputDecoration( - labelText: l10n.repeater_spreadingFactor, - border: const OutlineInputBorder(), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _codingRate, + decoration: InputDecoration( + labelText: l10n.repeater_codingRate, + ), + items: _codingRateOptions.map((cr) { + return DropdownMenuItem(value: cr, child: Text('4/$cr')); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _codingRate = value; + }); + _markChanged(_SettingField.radio); + } + }, ), - items: _spreadingFactorOptions.map((sf) { - return DropdownMenuItem(value: sf, child: Text('SF$sf')); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _spreadingFactor = value; - }); - _markChanged(_SettingField.radio); - } - }, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _codingRate, - decoration: InputDecoration( - labelText: l10n.repeater_codingRate, - border: const OutlineInputBorder(), + const SizedBox(height: 4), + _buildFeatureToggleRow( + title: l10n.repeater_rxGain, + subtitle: l10n.repeater_rxGainHelper, + value: _rxGainBoosted, + isRefreshing: _refreshingRxGain, + onChanged: (v) { + setState(() => _rxGainBoosted = v); + _markChanged(_SettingField.rxGain); + }, + onRefresh: _refreshRxGain, + refreshTooltip: l10n.repeater_refreshRxGain, ), - items: _codingRateOptions.map((cr) { - return DropdownMenuItem(value: cr, child: Text('4/$cr')); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _codingRate = value; - }); - _markChanged(_SettingField.radio); - } - }, - ), - const SizedBox(height: 8), - _buildFeatureToggleRow( - title: l10n.repeater_rxGain, - subtitle: l10n.repeater_rxGainHelper, - value: _rxGainBoosted, - isRefreshing: _refreshingRxGain, - onChanged: (v) { - setState(() => _rxGainBoosted = v); - _markChanged(_SettingField.rxGain); - }, - onRefresh: _refreshRxGain, - refreshTooltip: l10n.repeater_refreshRxGain, - ), - ], + ], + ), ), - ), + ], ); } Widget _buildLocationSettingsCard() { final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader( - icon: Icons.location_on, - title: l10n.repeater_locationSettings, - ), - const Divider(), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextField( - controller: _latController, - decoration: InputDecoration( - labelText: l10n.repeater_latitude, - helperText: l10n.repeater_latitudeHelper, - errorText: _latInvalid - ? l10n.settings_locationInvalid - : null, - border: const OutlineInputBorder(), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(l10n.repeater_locationSettings), + MeshCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _latController, + decoration: InputDecoration( + labelText: l10n.repeater_latitude, + helperText: l10n.repeater_latitudeHelper, + errorText: _latInvalid + ? l10n.settings_locationInvalid + : null, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + onChanged: (value) { + _markChanged(_SettingField.lat); + final invalid = !_isValidCoordinate(value, 90); + if (invalid != _latInvalid) { + setState(() => _latInvalid = invalid); + } + }, ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, - ), - onChanged: (value) { - _markChanged(_SettingField.lat); - final invalid = !_isValidCoordinate(value, 90); - if (invalid != _latInvalid) { - setState(() => _latInvalid = invalid); - } - }, ), - ), - const SizedBox(width: 8), - _buildInlineRefreshButton( - isRefreshing: _refreshingLat, - onRefresh: _refreshLat, - tooltip: l10n.repeater_latitude, - ), - ], - ), - const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextField( - controller: _lonController, - decoration: InputDecoration( - labelText: l10n.repeater_longitude, - helperText: l10n.repeater_longitudeHelper, - errorText: _lonInvalid - ? l10n.settings_locationInvalid - : null, - border: const OutlineInputBorder(), - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - signed: true, - ), - onChanged: (value) { - _markChanged(_SettingField.lon); - final invalid = !_isValidCoordinate(value, 180); - if (invalid != _lonInvalid) { - setState(() => _lonInvalid = invalid); - } - }, + _buildInlineRefreshButton( + isRefreshing: _refreshingLat, + onRefresh: _refreshLat, + tooltip: l10n.repeater_latitude, ), - ), - const SizedBox(width: 8), - _buildInlineRefreshButton( - isRefreshing: _refreshingLon, - onRefresh: _refreshLon, - tooltip: l10n.repeater_longitude, - ), - ], - ), - ], + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _lonController, + decoration: InputDecoration( + labelText: l10n.repeater_longitude, + helperText: l10n.repeater_longitudeHelper, + errorText: _lonInvalid + ? l10n.settings_locationInvalid + : null, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + onChanged: (value) { + _markChanged(_SettingField.lon); + final invalid = !_isValidCoordinate(value, 180); + if (invalid != _lonInvalid) { + setState(() => _lonInvalid = invalid); + } + }, + ), + ), + _buildInlineRefreshButton( + isRefreshing: _refreshingLon, + onRefresh: _refreshLon, + tooltip: l10n.repeater_longitude, + ), + ], + ), + ], + ), ), - ), + ], ); } Widget _buildFeatureTogglesCard() { final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.toggle_on, - color: Theme.of(context).textTheme.headlineSmall?.color, - ), - const SizedBox(width: 8), - Text( - l10n.repeater_features, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const Divider(), - _buildFeatureToggleRow( - title: l10n.repeater_packetForwarding, - subtitle: l10n.repeater_packetForwardingSubtitle, - value: _repeatEnabled, - isRefreshing: _refreshingRepeat, - onChanged: (value) { - setState(() { - _repeatEnabled = value; - }); - _markChanged(_SettingField.repeat); - }, - onRefresh: _refreshRepeat, - refreshTooltip: l10n.repeater_refreshPacketForwarding, - ), - _buildFeatureToggleRow( - title: l10n.repeater_guestAccess, - subtitle: l10n.repeater_guestAccessSubtitle, - value: _allowReadOnly, - isRefreshing: _refreshingAllowReadOnly, - onChanged: (value) { - setState(() { - _allowReadOnly = value; - }); - _markChanged(_SettingField.allowReadOnly); - }, - onRefresh: _refreshAllowReadOnly, - refreshTooltip: l10n.repeater_refreshGuestAccess, - ), - _buildFeatureToggleRow( - title: l10n.repeater_multiAcks, - subtitle: l10n.repeater_multiAcksSubtitle, - value: _multiAcks, - isRefreshing: _refreshingMultiAcks, - onChanged: (v) { - setState(() => _multiAcks = v); - _markChanged(_SettingField.multiAcks); - }, - onRefresh: _refreshMultiAcks, - refreshTooltip: l10n.repeater_refreshMultiAcks, - ), - SwitchListTile( - title: Text(l10n.repeater_clockSyncAfterLogin), - subtitle: Text(l10n.repeater_clockSyncAfterLoginSubtitle), - value: _autoClockSyncAfterLogin, - onChanged: (value) async { - setState(() { - _autoClockSyncAfterLogin = value; - }); - await _storage.setRepeaterAutoClockSyncAfterLoginEnabled( - widget.repeater.publicKeyHex, - value, - ); - }, - contentPadding: EdgeInsets.zero, - ), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(l10n.repeater_features), + MeshCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFeatureToggleRow( + title: l10n.repeater_packetForwarding, + subtitle: l10n.repeater_packetForwardingSubtitle, + value: _repeatEnabled, + isRefreshing: _refreshingRepeat, + onChanged: (value) { + setState(() { + _repeatEnabled = value; + }); + _markChanged(_SettingField.repeat); + }, + onRefresh: _refreshRepeat, + refreshTooltip: l10n.repeater_refreshPacketForwarding, + ), + _buildFeatureToggleRow( + title: l10n.repeater_guestAccess, + subtitle: l10n.repeater_guestAccessSubtitle, + value: _allowReadOnly, + isRefreshing: _refreshingAllowReadOnly, + onChanged: (value) { + setState(() { + _allowReadOnly = value; + }); + _markChanged(_SettingField.allowReadOnly); + }, + onRefresh: _refreshAllowReadOnly, + refreshTooltip: l10n.repeater_refreshGuestAccess, + ), + _buildFeatureToggleRow( + title: l10n.repeater_multiAcks, + subtitle: l10n.repeater_multiAcksSubtitle, + value: _multiAcks, + isRefreshing: _refreshingMultiAcks, + onChanged: (v) { + setState(() => _multiAcks = v); + _markChanged(_SettingField.multiAcks); + }, + onRefresh: _refreshMultiAcks, + refreshTooltip: l10n.repeater_refreshMultiAcks, + ), + SwitchListTile( + title: Text(l10n.repeater_clockSyncAfterLogin), + subtitle: Text(l10n.repeater_clockSyncAfterLoginSubtitle), + value: _autoClockSyncAfterLogin, + onChanged: (value) async { + setState(() { + _autoClockSyncAfterLogin = value; + }); + await _storage.setRepeaterAutoClockSyncAfterLoginEnabled( + widget.repeater.publicKeyHex, + value, + ); + }, + contentPadding: EdgeInsets.zero, + ), + ], + ), ), - ), + ], ); } @@ -1531,388 +1464,370 @@ class _RepeaterSettingsScreenState extends State { Widget _buildAdvertisementSettingsCard() { final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader( - icon: Icons.broadcast_on_personal, - title: l10n.repeater_advertisementSettings, - ), - const Divider(), - Row( - children: [ - Expanded( - child: ListTile( - title: Text(l10n.repeater_localAdvertInterval), - subtitle: Text( - l10n.repeater_localAdvertIntervalMinutes(_advertInterval), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(l10n.repeater_advertisementSettings), + MeshCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ListTile( + title: Text(l10n.repeater_localAdvertInterval), + subtitle: Text( + l10n.repeater_localAdvertIntervalMinutes(_advertInterval), + ), + trailing: Switch( + value: _advertEnable, + onChanged: (value) { + setState(() { + _advertInterval = value ? 60 : 0; + _advertEnable = value; + }); + _markChanged(_SettingField.advertInterval); + }, + ), + contentPadding: EdgeInsets.zero, ), - trailing: Switch( - value: _advertEnable, - onChanged: (value) { + ), + IconButton( + icon: _refreshingAdvertInterval + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 20), + onPressed: _refreshingAdvertInterval + ? null + : _refreshAdvertInterval, + tooltip: l10n.repeater_localAdvertInterval, + visualDensity: VisualDensity.compact, + ), + ], + ), + Slider( + value: _advertInterval == 0 + ? 60.toDouble() + : _advertInterval.toDouble(), + min: 60, + max: 240, + divisions: 18, + label: l10n.repeater_localAdvertIntervalMinutes(_advertInterval), + onChanged: _advertEnable + ? (value) { setState(() { - _advertInterval = value ? 60 : 0; - _advertEnable = value; + _advertInterval = value.toInt(); }); _markChanged(_SettingField.advertInterval); - }, - ), - contentPadding: EdgeInsets.zero, - ), - ), - IconButton( - icon: _refreshingAdvertInterval - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh, size: 20), - onPressed: _refreshingAdvertInterval - ? null - : _refreshAdvertInterval, - tooltip: l10n.repeater_localAdvertInterval, - visualDensity: VisualDensity.compact, - ), - ], - ), - Slider( - value: _advertInterval == 0 - ? 60.toDouble() - : _advertInterval.toDouble(), - min: 60, - max: 240, - divisions: 18, - label: l10n.repeater_localAdvertIntervalMinutes(_advertInterval), - onChanged: _advertEnable - ? (value) { - setState(() { - _advertInterval = value.toInt(); - }); - _markChanged(_SettingField.advertInterval); - } - : null, - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ListTile( - title: Text(l10n.repeater_floodAdvertInterval), - subtitle: Text( - l10n.repeater_floodAdvertIntervalHours( - _floodAdvertInterval, + } + : null, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ListTile( + title: Text(l10n.repeater_floodAdvertInterval), + subtitle: Text( + l10n.repeater_floodAdvertIntervalHours( + _floodAdvertInterval, + ), ), + trailing: Switch( + value: _floodAdvertEnable, + onChanged: (value) { + setState(() { + _floodAdvertInterval = value ? 3 : 0; + _floodAdvertEnable = value; + }); + _markChanged(_SettingField.floodAdvertInterval); + }, + ), + contentPadding: EdgeInsets.zero, ), - trailing: Switch( - value: _floodAdvertEnable, - onChanged: (value) { + ), + IconButton( + icon: _refreshingFloodAdvertInterval + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 20), + onPressed: _refreshingFloodAdvertInterval + ? null + : _refreshFloodAdvertInterval, + tooltip: l10n.repeater_floodAdvertInterval, + visualDensity: VisualDensity.compact, + ), + ], + ), + Slider( + value: _floodAdvertInterval == 0 + ? 3.toDouble() + : _floodAdvertInterval.toDouble(), + min: 3, + max: 168, + divisions: 165, + label: l10n.repeater_floodAdvertIntervalHours( + _floodAdvertInterval, + ), + onChanged: _floodAdvertEnable + ? (value) { setState(() { - _floodAdvertInterval = value ? 3 : 0; - _floodAdvertEnable = value; + _floodAdvertInterval = value.toInt(); }); _markChanged(_SettingField.floodAdvertInterval); - }, - ), - contentPadding: EdgeInsets.zero, - ), - ), - IconButton( - icon: _refreshingFloodAdvertInterval - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh, size: 20), - onPressed: _refreshingFloodAdvertInterval - ? null - : _refreshFloodAdvertInterval, - tooltip: l10n.repeater_floodAdvertInterval, - visualDensity: VisualDensity.compact, - ), - ], - ), - Slider( - value: _floodAdvertInterval == 0 - ? 3.toDouble() - : _floodAdvertInterval.toDouble(), - min: 3, - max: 168, - divisions: 165, - label: l10n.repeater_floodAdvertIntervalHours( - _floodAdvertInterval, + } + : null, ), - onChanged: _floodAdvertEnable - ? (value) { - setState(() { - _floodAdvertInterval = value.toInt(); - }); - _markChanged(_SettingField.floodAdvertInterval); - } - : null, - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ListTile( - title: Text(l10n.repeater_floodMax), - subtitle: Text(l10n.repeater_floodMaxHelper), - trailing: Text( - '$_floodMax', - style: const TextStyle(fontWeight: FontWeight.bold), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ListTile( + title: Text(l10n.repeater_floodMax), + subtitle: Text(l10n.repeater_floodMaxHelper), + trailing: Text( + '$_floodMax', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + contentPadding: EdgeInsets.zero, ), - contentPadding: EdgeInsets.zero, ), - ), - IconButton( - icon: _refreshingFloodMax - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh, size: 20), - onPressed: _refreshingFloodMax ? null : _refreshFloodMax, - tooltip: l10n.repeater_floodMax, - visualDensity: VisualDensity.compact, - ), - ], - ), - Slider( - value: _floodMax.toDouble(), - min: 0, - max: 64, - divisions: 64, - label: '$_floodMax', - onChanged: (v) { - setState(() => _floodMax = v.toInt()); - _markChanged(_SettingField.floodMax); - }, - ), - ], + IconButton( + icon: _refreshingFloodMax + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 20), + onPressed: _refreshingFloodMax ? null : _refreshFloodMax, + tooltip: l10n.repeater_floodMax, + visualDensity: VisualDensity.compact, + ), + ], + ), + Slider( + value: _floodMax.toDouble(), + min: 0, + max: 64, + divisions: 64, + label: '$_floodMax', + onChanged: (v) { + setState(() => _floodMax = v.toInt()); + _markChanged(_SettingField.floodMax); + }, + ), + ], + ), ), - ), + ], ); } Widget _buildNetworkHealthCard() { final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader( - icon: Icons.health_and_safety, - title: l10n.repeater_networkHealth, - ), - const Divider(), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: DropdownButtonFormField( - initialValue: _loopDetect, - decoration: InputDecoration( - labelText: l10n.repeater_loopDetect, - helperText: l10n.repeater_loopDetectHelper, - helperMaxLines: 3, - border: const OutlineInputBorder(), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(l10n.repeater_networkHealth), + MeshCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: _loopDetect, + decoration: InputDecoration( + labelText: l10n.repeater_loopDetect, + helperText: l10n.repeater_loopDetectHelper, + helperMaxLines: 3, + ), + items: [ + DropdownMenuItem( + value: 'off', + child: Text(l10n.repeater_loopDetectOff), + ), + DropdownMenuItem( + value: 'minimal', + child: Text(l10n.repeater_loopDetectMinimal), + ), + DropdownMenuItem( + value: 'moderate', + child: Text(l10n.repeater_loopDetectModerate), + ), + DropdownMenuItem( + value: 'strict', + child: Text(l10n.repeater_loopDetectStrict), + ), + ], + onChanged: (v) { + if (v != null) { + setState(() => _loopDetect = v); + _markChanged(_SettingField.loopDetect); + } + }, ), - items: [ - DropdownMenuItem( - value: 'off', - child: Text(l10n.repeater_loopDetectOff), - ), - DropdownMenuItem( - value: 'minimal', - child: Text(l10n.repeater_loopDetectMinimal), - ), - DropdownMenuItem( - value: 'moderate', - child: Text(l10n.repeater_loopDetectModerate), - ), - DropdownMenuItem( - value: 'strict', - child: Text(l10n.repeater_loopDetectStrict), - ), - ], - onChanged: (v) { - if (v != null) { - setState(() => _loopDetect = v); - _markChanged(_SettingField.loopDetect); - } - }, ), - ), - const SizedBox(width: 8), - _buildInlineRefreshButton( - isRefreshing: _refreshingLoopDetect, - onRefresh: _refreshLoopDetect, - tooltip: l10n.repeater_loopDetect, - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ListTile( - title: Text(l10n.repeater_dutyCycle), - subtitle: Text(l10n.repeater_dutyCycleHelper), - trailing: Text( - l10n.repeater_dutyCyclePercent(_dutyCycle), - style: const TextStyle(fontWeight: FontWeight.bold), + _buildInlineRefreshButton( + isRefreshing: _refreshingLoopDetect, + onRefresh: _refreshLoopDetect, + tooltip: l10n.repeater_loopDetect, + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ListTile( + title: Text(l10n.repeater_dutyCycle), + subtitle: Text(l10n.repeater_dutyCycleHelper), + trailing: Text( + l10n.repeater_dutyCyclePercent(_dutyCycle), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + contentPadding: EdgeInsets.zero, ), - contentPadding: EdgeInsets.zero, ), - ), - IconButton( - icon: _refreshingDutyCycle - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh, size: 20), - onPressed: _refreshingDutyCycle ? null : _refreshDutyCycle, - tooltip: l10n.repeater_dutyCycle, - visualDensity: VisualDensity.compact, - ), - ], - ), - Slider( - value: _dutyCycle.toDouble(), - min: 1, - max: 100, - divisions: 99, - label: l10n.repeater_dutyCyclePercent(_dutyCycle), - onChanged: (v) { - setState(() => _dutyCycle = v.toInt()); - _markChanged(_SettingField.dutyCycle); - }, - ), - ], + IconButton( + icon: _refreshingDutyCycle + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 20), + onPressed: _refreshingDutyCycle ? null : _refreshDutyCycle, + tooltip: l10n.repeater_dutyCycle, + visualDensity: VisualDensity.compact, + ), + ], + ), + Slider( + value: _dutyCycle.toDouble(), + min: 1, + max: 100, + divisions: 99, + label: l10n.repeater_dutyCyclePercent(_dutyCycle), + onChanged: (v) { + setState(() => _dutyCycle = v.toInt()); + _markChanged(_SettingField.dutyCycle); + }, + ), + ], + ), ), - ), + ], ); } Widget _buildOwnerInfoCard() { final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader( - icon: Icons.person_outline, - title: l10n.repeater_ownerInfo, - tooltip: l10n.repeater_refreshOwnerInfo, - isRefreshing: _refreshingOwnerInfo, - onRefresh: _refreshOwnerInfo, + final refreshButton = _refreshingOwnerInfo + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: _refreshOwnerInfo, + tooltip: l10n.repeater_refreshOwnerInfo, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(l10n.repeater_ownerInfo, trailing: refreshButton), + MeshCard( + child: TextField( + controller: _ownerInfoController, + decoration: InputDecoration( + labelText: l10n.repeater_ownerInfo, + helperText: l10n.repeater_ownerInfoHelper, + helperMaxLines: 3, ), - const Divider(), - TextField( - controller: _ownerInfoController, - decoration: InputDecoration( - labelText: l10n.repeater_ownerInfo, - helperText: l10n.repeater_ownerInfoHelper, - helperMaxLines: 3, - border: const OutlineInputBorder(), - ), - maxLines: 4, - minLines: 2, - onChanged: (_) => _markChanged(_SettingField.ownerInfo), - ), - ], + maxLines: 4, + minLines: 2, + onChanged: (_) => _markChanged(_SettingField.ownerInfo), + ), ), - ), + ], ); } Widget _buildActionsCard() { final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.flash_on, - color: Theme.of(context).textTheme.headlineSmall?.color, - ), - const SizedBox(width: 8), - Text( - l10n.repeater_actionsTitle, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const Divider(), - ListTile( - leading: const Icon(Icons.podcasts), - title: Text(l10n.repeater_sendAdvert), - subtitle: Text(l10n.repeater_sendAdvertSubtitle), - enabled: !_runningAction, - onTap: _runningAction - ? null - : () => _runAction('advert', l10n.repeater_sendAdvert), - contentPadding: EdgeInsets.zero, - ), - ListTile( - leading: const Icon(Icons.cell_tower), - title: Text(l10n.repeater_sendAdvertZeroHop), - subtitle: Text(l10n.repeater_sendAdvertZeroHopSubtitle), - enabled: !_runningAction, - onTap: _runningAction - ? null - : () => _runAction( - 'advert.zerohop', - l10n.repeater_sendAdvertZeroHop, - ), - contentPadding: EdgeInsets.zero, - ), - ListTile( - leading: const Icon(Icons.access_time), - title: Text(l10n.repeater_clockSync), - subtitle: Text(l10n.repeater_clockSyncSubtitle), - enabled: !_runningAction, - onTap: _runningAction - ? null - : () => _runAction('clock sync', l10n.repeater_clockSync), - contentPadding: EdgeInsets.zero, - ), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(l10n.repeater_actionsTitle), + MeshCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: const Icon(Icons.podcasts), + title: Text(l10n.repeater_sendAdvert), + subtitle: Text(l10n.repeater_sendAdvertSubtitle), + enabled: !_runningAction, + onTap: _runningAction + ? null + : () => _runAction('advert', l10n.repeater_sendAdvert), + contentPadding: EdgeInsets.zero, + ), + ListTile( + leading: const Icon(Icons.cell_tower), + title: Text(l10n.repeater_sendAdvertZeroHop), + subtitle: Text(l10n.repeater_sendAdvertZeroHopSubtitle), + enabled: !_runningAction, + onTap: _runningAction + ? null + : () => _runAction( + 'advert.zerohop', + l10n.repeater_sendAdvertZeroHop, + ), + contentPadding: EdgeInsets.zero, + ), + ListTile( + leading: const Icon(Icons.access_time), + title: Text(l10n.repeater_clockSync), + subtitle: Text(l10n.repeater_clockSyncSubtitle), + enabled: !_runningAction, + onTap: _runningAction + ? null + : () => _runAction('clock sync', l10n.repeater_clockSync), + contentPadding: EdgeInsets.zero, + ), + ], + ), ), - ), + ], ); } Widget _buildAdvancedCard() { final l10n = context.l10n; - return Card( + return MeshCard( child: ExpansionTile( leading: const Icon(Icons.tune), title: Text( l10n.repeater_advancedSettings, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), ), subtitle: Text(l10n.repeater_advancedSettingsSubtitle), - childrenPadding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + childrenPadding: const EdgeInsets.fromLTRB(0, 8, 0, 4), children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -1924,7 +1839,6 @@ class _RepeaterSettingsScreenState extends State { labelText: l10n.repeater_pathHashMode, helperText: l10n.repeater_pathHashModeHelper, helperMaxLines: 5, - border: const OutlineInputBorder(), ), items: const [ DropdownMenuItem(value: 0, child: Text('0')), @@ -1939,7 +1853,6 @@ class _RepeaterSettingsScreenState extends State { }, ), ), - const SizedBox(width: 8), _buildInlineRefreshButton( isRefreshing: _refreshingPathHashMode, onRefresh: _refreshPathHashMode, @@ -1947,7 +1860,7 @@ class _RepeaterSettingsScreenState extends State { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1958,7 +1871,6 @@ class _RepeaterSettingsScreenState extends State { labelText: l10n.repeater_txDelay, helperText: l10n.repeater_txDelayHelper, helperMaxLines: 3, - border: const OutlineInputBorder(), ), keyboardType: const TextInputType.numberWithOptions( decimal: true, @@ -1966,7 +1878,6 @@ class _RepeaterSettingsScreenState extends State { onChanged: (_) => _markChanged(_SettingField.txDelay), ), ), - const SizedBox(width: 8), _buildInlineRefreshButton( isRefreshing: _refreshingTxDelay, onRefresh: _refreshTxDelay, @@ -1974,7 +1885,7 @@ class _RepeaterSettingsScreenState extends State { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1985,7 +1896,6 @@ class _RepeaterSettingsScreenState extends State { labelText: l10n.repeater_directTxDelay, helperText: l10n.repeater_directTxDelayHelper, helperMaxLines: 3, - border: const OutlineInputBorder(), ), keyboardType: const TextInputType.numberWithOptions( decimal: true, @@ -1993,7 +1903,6 @@ class _RepeaterSettingsScreenState extends State { onChanged: (_) => _markChanged(_SettingField.directTxDelay), ), ), - const SizedBox(width: 8), _buildInlineRefreshButton( isRefreshing: _refreshingDirectTxDelay, onRefresh: _refreshDirectTxDelay, @@ -2001,7 +1910,7 @@ class _RepeaterSettingsScreenState extends State { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -2012,13 +1921,11 @@ class _RepeaterSettingsScreenState extends State { labelText: l10n.repeater_intThresh, helperText: l10n.repeater_intThreshHelper, helperMaxLines: 3, - border: const OutlineInputBorder(), ), keyboardType: TextInputType.number, onChanged: (_) => _markChanged(_SettingField.intThresh), ), ), - const SizedBox(width: 8), _buildInlineRefreshButton( isRefreshing: _refreshingIntThresh, onRefresh: _refreshIntThresh, @@ -2026,7 +1933,7 @@ class _RepeaterSettingsScreenState extends State { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), Row( children: [ Expanded( @@ -2077,85 +1984,72 @@ class _RepeaterSettingsScreenState extends State { Widget _buildDangerZoneCard() { final l10n = context.l10n; - final colorScheme = Theme.of(context).colorScheme; - return Card( - color: colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.warning, color: colorScheme.onErrorContainer), - const SizedBox(width: 8), - Text( - l10n.repeater_dangerZone, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: colorScheme.onErrorContainer, - ), - ), - ], - ), - const Divider(), - ListTile( - leading: Icon(Icons.refresh, color: colorScheme.onErrorContainer), - title: Text( - l10n.repeater_rebootRepeater, - style: TextStyle(color: colorScheme.onErrorContainer), - ), - subtitle: Text( - l10n.repeater_rebootRepeaterSubtitle, - style: TextStyle( - color: colorScheme.onErrorContainer.withValues(alpha: 0.8), + return MeshCard( + color: MeshPalette.alertBg, + borderColor: MeshPalette.alertLine, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.warning, color: MeshPalette.alert), + const SizedBox(width: 8), + Text( + l10n.repeater_dangerZone, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: MeshPalette.alert, ), ), - onTap: () => _confirmAction( - l10n.repeater_rebootRepeater, - l10n.repeater_rebootRepeaterConfirm, - () => _sendDangerCommand('reboot'), + ], + ), + const Divider(height: 20, color: MeshPalette.alertLine), + ListTile( + leading: const Icon(Icons.refresh, color: MeshPalette.alert), + title: Text( + l10n.repeater_rebootRepeater, + style: const TextStyle(color: MeshPalette.alert), + ), + subtitle: Text( + l10n.repeater_rebootRepeaterSubtitle, + style: const TextStyle( + color: MeshPalette.warnDim, ), ), - // Regenerate identity key - hidden until fully implemented - // ListTile( - // leading: Icon(Icons.vpn_key, color: colorScheme.onErrorContainer), - // title: Text('Regenerate Identity Key', style: TextStyle(color: colorScheme.onErrorContainer)), - // subtitle: Text( - // 'Generate new public/private key pair', - // style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)), - // ), - // onTap: () => _confirmAction( - // 'Regenerate Identity', - // 'This will generate a new identity for the repeater. Continue?', - // () => _sendDangerCommand('regen key'), - // ), - // ), - ListTile( - leading: Icon( - Icons.delete_forever, - color: colorScheme.onErrorContainer, - ), - title: Text( - l10n.repeater_eraseFileSystem, - style: TextStyle(color: colorScheme.onErrorContainer), - ), - subtitle: Text( - l10n.repeater_eraseFileSystemSubtitle, - style: TextStyle( - color: colorScheme.onErrorContainer.withValues(alpha: 0.8), - ), - ), - onTap: () => _confirmAction( - l10n.repeater_eraseFileSystem, - l10n.repeater_eraseFileSystemConfirm, - () => _sendDangerCommand('erase'), - isDestructive: true, + onTap: () => _confirmAction( + l10n.repeater_rebootRepeater, + l10n.repeater_rebootRepeaterConfirm, + () => _sendDangerCommand('reboot'), + ), + contentPadding: EdgeInsets.zero, + ), + // Regenerate identity key - hidden until fully implemented + ListTile( + leading: const Icon( + Icons.delete_forever, + color: MeshPalette.alert, + ), + title: Text( + l10n.repeater_eraseFileSystem, + style: const TextStyle(color: MeshPalette.alert), + ), + subtitle: Text( + l10n.repeater_eraseFileSystemSubtitle, + style: const TextStyle( + color: MeshPalette.warnDim, ), ), - ], - ), + onTap: () => _confirmAction( + l10n.repeater_eraseFileSystem, + l10n.repeater_eraseFileSystemConfirm, + () => _sendDangerCommand('erase'), + isDestructive: true, + ), + contentPadding: EdgeInsets.zero, + ), + ], ), ); } diff --git a/lib/screens/repeater_status_screen.dart b/lib/screens/repeater_status_screen.dart index f121605d..080d5b7f 100644 --- a/lib/screens/repeater_status_screen.dart +++ b/lib/screens/repeater_status_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; @@ -10,7 +11,9 @@ import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../services/app_settings_service.dart'; import '../services/repeater_command_service.dart'; +import '../theme/mesh_theme.dart'; import '../utils/battery_utils.dart'; +import '../widgets/mesh_ui.dart'; import '../widgets/routing_sheet.dart'; import '../helpers/snack_bar_builder.dart'; @@ -64,8 +67,6 @@ class _RepeaterStatusScreenState extends State { final connector = Provider.of(context, listen: false); _commandService = RepeaterCommandService(connector); _setupMessageListener(); - // Defer until after the first frame so any notifyListeners() triggered - // during preparePathForContactSend doesn't fire mid-build. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _loadStatus(); }); @@ -81,12 +82,8 @@ class _RepeaterStatusScreenState extends State { void _setupMessageListener() { final connector = Provider.of(context, listen: false); - - // Listen for incoming text messages from the repeater _frameSubscription = connector.receivedFrames.listen((frame) { if (frame.isEmpty) return; - - // Check if it's a text message response if (frame[0] == pushCodeStatusResponse) { _handleStatusResponse(frame); } else if (frame[0] == respCodeContactMsgRecv || @@ -118,11 +115,7 @@ class _RepeaterStatusScreenState extends State { final parsed = parseContactMessageText(frame); if (parsed == null) return; if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return; - - // Notify command service of response (for retry handling) _commandService?.handleResponse(widget.repeater, parsed.text); - - // Parse status responses _parseStatusResponse(parsed.text); _recordStatusResult(true); } @@ -131,7 +124,6 @@ class _RepeaterStatusScreenState extends State { if (frame.length < 8) return; final prefix = frame.sublist(2, 8); if (!_matchesRepeaterPrefix(prefix)) return; - if (frame.length < _statusResponseBytes) return; final data = ByteData.sublistView( @@ -254,14 +246,9 @@ class _RepeaterStatusScreenState extends State { _dupFlood = _asInt(data['dup_flood']); _dupDirect = _asInt(data['dup_direct']); } - } catch (_) { - // Ignore parse failures for non-JSON responses. - } - } - - if (mounted) { - setState(() {}); + } catch (_) {} } + if (mounted) setState(() {}); } Future _loadStatus() async { @@ -302,9 +289,7 @@ class _RepeaterStatusScreenState extends State { var messageBytes = frame.length >= _statusResponseBytes ? frame.length : _statusResponseBytes; - if (messageBytes < maxFrameSize) { - messageBytes = maxFrameSize; - } + if (messageBytes < maxFrameSize) messageBytes = maxFrameSize; final timeoutMs = connector.calculateTimeout( pathLength: pathLengthValue, messageBytes: messageBytes, @@ -312,9 +297,7 @@ class _RepeaterStatusScreenState extends State { _statusTimeout?.cancel(); _statusTimeout = Timer(Duration(milliseconds: timeoutMs), () { if (!mounted) return; - setState(() { - _isLoading = false; - }); + setState(() => _isLoading = false); showDismissibleSnackBar( context, content: Text(context.l10n.repeater_statusRequestTimeout), @@ -324,10 +307,7 @@ class _RepeaterStatusScreenState extends State { }); } catch (e) { if (mounted) { - setState(() { - _isLoading = false; - }); - + setState(() => _isLoading = false); showDismissibleSnackBar( context, content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())), @@ -347,214 +327,6 @@ class _RepeaterStatusScreenState extends State { _pendingStatusSelection = null; } - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final connector = context.watch(); - final repeater = _resolveRepeater(connector); - final isFloodMode = repeater.pathOverride == -1; - - return Scaffold( - appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.repeater_statusTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - repeater.name, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.normal, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - centerTitle: false, - actions: [ - IconButton( - icon: Icon(isFloodMode ? Icons.waves : Icons.route), - tooltip: l10n.repeater_routingMode, - onPressed: () => - ContactRoutingSheet.show(context, contact: repeater), - ), - IconButton( - icon: _isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh), - onPressed: _isLoading ? null : _loadStatus, - tooltip: l10n.repeater_refresh, - ), - ], - ), - body: SafeArea( - top: false, - child: RefreshIndicator( - onRefresh: _loadStatus, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildSystemInfoCard(), - const SizedBox(height: 16), - _buildRadioStatsCard(), - const SizedBox(height: 16), - _buildPacketStatsCard(), - ], - ), - ), - ), - ); - } - - Widget _buildSystemInfoCard() { - final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.info_outline, - color: Theme.of(context).textTheme.headlineSmall?.color, - ), - const SizedBox(width: 8), - Text( - l10n.repeater_systemInformation, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const Divider(), - _buildInfoRow(l10n.repeater_battery, _batteryText()), - _buildInfoRow(l10n.repeater_clockAtLogin, _clockText()), - _buildInfoRow(l10n.repeater_uptime, _formatDuration(_uptimeSecs)), - _buildInfoRow(l10n.repeater_queueLength, _formatValue(_queueLen)), - _buildInfoRow(l10n.repeater_debugFlags, _formatValue(_debugFlags)), - ], - ), - ), - ); - } - - Widget _buildRadioStatsCard() { - final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.radio, - color: Theme.of(context).textTheme.headlineSmall?.color, - ), - const SizedBox(width: 8), - Text( - l10n.repeater_radioStatistics, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const Divider(), - _buildInfoRow( - l10n.repeater_lastRssi, - _formatValue(_lastRssi, suffix: ' dB'), - ), - _buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)), - _buildInfoRow( - l10n.repeater_noiseFloor, - _formatValue(_noiseFloor, suffix: ' dB'), - ), - _buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)), - _buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)), - ], - ), - ), - ); - } - - Widget _buildPacketStatsCard() { - final l10n = context.l10n; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.analytics, - color: Theme.of(context).textTheme.headlineSmall?.color, - ), - const SizedBox(width: 8), - Text( - l10n.repeater_packetStatistics, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const Divider(), - _buildInfoRow(l10n.repeater_sent, _packetTxText()), - _buildInfoRow(l10n.repeater_received, _packetRxText()), - _buildInfoRow(l10n.repeater_duplicates, _duplicateText()), - _buildInfoRow(l10n.repeater_chanUtil, _chanUtilText()), - ], - ), - ), - ); - } - - Widget _buildInfoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - label, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: 8), - Text( - value, - style: const TextStyle(fontWeight: FontWeight.w400), - textAlign: TextAlign.end, - ), - ], - ), - ); - } - int? _asInt(dynamic value) { if (value == null) return null; if (value is int) return value; @@ -661,4 +433,218 @@ class _RepeaterStatusScreenState extends State { if (snr == null) return '—'; return snr.toStringAsFixed(2); } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final connector = context.watch(); + final repeater = _resolveRepeater(connector); + final isFloodMode = repeater.pathOverride == -1; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.repeater_statusTitle), + centerTitle: true, + actions: [ + IconButton( + icon: Icon(isFloodMode ? Icons.waves : Icons.route), + tooltip: l10n.repeater_routingMode, + onPressed: () => + ContactRoutingSheet.show(context, contact: repeater), + ), + IconButton( + icon: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + onPressed: _isLoading ? null : _loadStatus, + tooltip: l10n.repeater_refresh, + ), + ], + ), + body: SafeArea( + top: false, + child: RefreshIndicator( + onRefresh: _loadStatus, + child: _isLoading && _batteryMv == null + ? const Center(child: CircularProgressIndicator()) + : _buildBody(l10n, repeater.name), + ), + ), + ); + } + + Widget _buildBody(dynamic l10n, String name) { + final scheme = Theme.of(context).colorScheme; + return ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + // ── System ───────────────────────────────────────────────────────── + SectionHeader(l10n.repeater_systemInformation), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _buildStatGrid([ + _StatItem( + icon: Icons.battery_std, + label: l10n.repeater_battery, + value: _batteryText(), + color: _batteryColor(), + ), + _StatItem( + icon: Icons.timer_outlined, + label: l10n.repeater_uptime, + value: _formatDuration(_uptimeSecs), + color: MeshPalette.blue, + ), + _StatItem( + icon: Icons.schedule, + label: l10n.repeater_clockAtLogin, + value: _clockText(), + color: scheme.onSurfaceVariant, + ), + _StatItem( + icon: Icons.inbox, + label: l10n.repeater_queueLength, + value: _formatValue(_queueLen), + color: scheme.onSurfaceVariant, + ), + _StatItem( + icon: Icons.bug_report_outlined, + label: l10n.repeater_debugFlags, + value: _formatValue(_debugFlags), + color: _debugFlags != null && _debugFlags! > 0 + ? MeshPalette.warn + : scheme.onSurfaceVariant, + ), + ]), + ), + + // ── Radio ────────────────────────────────────────────────────────── + SectionHeader(l10n.repeater_radioStatistics), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _buildStatGrid([ + _StatItem( + icon: Icons.signal_cellular_alt, + label: l10n.repeater_lastRssi, + value: _formatValue(_lastRssi, suffix: ' dB'), + color: MeshPalette.blue, + ), + _StatItem( + icon: Icons.waves, + label: l10n.repeater_lastSnr, + value: _formatSnr(_lastSnr), + color: MeshTheme.snrColor(_lastSnr, blocked: false), + ), + _StatItem( + icon: Icons.noise_control_off, + label: l10n.repeater_noiseFloor, + value: _formatValue(_noiseFloor, suffix: ' dB'), + color: scheme.onSurfaceVariant, + ), + _StatItem( + icon: Icons.upload, + label: l10n.repeater_txAirtime, + value: _formatDuration(_txAirSecs), + color: MeshPalette.warn, + ), + _StatItem( + icon: Icons.download, + label: l10n.repeater_rxAirtime, + value: _formatDuration(_rxAirSecs), + color: MeshPalette.signal, + ), + ]), + ), + + // ── Packets ──────────────────────────────────────────────────────── + SectionHeader(l10n.repeater_packetStatistics), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _buildStatGrid([ + _StatItem( + icon: Icons.send, + label: l10n.repeater_sent, + value: _packetTxText(), + color: MeshPalette.blue, + ), + _StatItem( + icon: Icons.call_received, + label: l10n.repeater_received, + value: _packetRxText(), + color: MeshPalette.signal, + ), + _StatItem( + icon: Icons.content_copy, + label: l10n.repeater_duplicates, + value: _duplicateText(), + color: scheme.onSurfaceVariant, + ), + _StatItem( + icon: Icons.percent, + label: l10n.repeater_chanUtil, + value: _chanUtilText(), + color: _chanUtil != null && _chanUtil! > 80 + ? MeshPalette.alert + : _chanUtil != null && _chanUtil! > 50 + ? MeshPalette.warn + : MeshPalette.signal, + ), + ]), + ), + const SizedBox(height: 8), + ], + ); + } + + Color _batteryColor() { + final connector = context.watch(); + final batteryMv = + connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ?? + _batteryMv; + if (batteryMv == null) return Theme.of(context).colorScheme.onSurfaceVariant; + final percent = estimateBatteryPercentFromMillivolts( + batteryMv, + _batteryChemistry(), + ); + if (percent < 20) return MeshPalette.alert; + if (percent < 40) return MeshPalette.warn; + return MeshPalette.signal; + } + + Widget _buildStatGrid(List<_StatItem> items) { + return GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 2.2, + children: items + .map((item) => StatTile( + icon: item.icon, + label: item.label, + value: item.value, + color: item.color, + )) + .toList(), + ); + } +} + +class _StatItem { + final IconData icon; + final String label; + final String value; + final Color color; + + const _StatItem({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); } diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index 1fd2c53c..212ab819 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../utils/platform_info.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:provider/provider.dart'; @@ -7,10 +8,12 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../services/linux_ble_error_classifier.dart'; +import '../theme/mesh_theme.dart'; import '../utils/app_logger.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/device_tile.dart'; import '../widgets/empty_state.dart'; +import '../widgets/mesh_ui.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; import 'tcp_screen.dart'; @@ -136,12 +139,21 @@ class _ScannerScreenState extends State { builder: (context, connector, child) { return Column( children: [ - // Bluetooth off warning - if (_bluetoothState == BluetoothAdapterState.off) - _bluetoothOffWarning(context), + // Bluetooth off warning — slides in/out with AnimatedSize + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: _bluetoothState == BluetoothAdapterState.off + ? _BluetoothOffBanner( + onEnable: PlatformInfo.isAndroid + ? () => FlutterBluePlus.turnOn() + : null, + ) + : const SizedBox.shrink(), + ), - // Status bar - _buildStatusBar(context, connector), + // Connection status header + _ConnectionStatusHeader(connector: connector), // Device list Expanded(child: _buildDeviceList(context, connector)), @@ -158,14 +170,31 @@ class _ScannerScreenState extends State { return FloatingActionButton.extended( heroTag: 'scanner_ble_action', - onPressed: isBluetoothOff ? null : () => _toggleScan(connector), - icon: isScanning - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.bluetooth_searching), + onPressed: isBluetoothOff + ? null + : () { + HapticFeedback.lightImpact(); + _toggleScan(connector); + }, + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + transitionBuilder: (child, anim) => + ScaleTransition(scale: anim, child: child), + child: isScanning + ? SizedBox( + key: const ValueKey('scanning'), + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onPrimary, + ), + ) + : const Icon( + Icons.bluetooth_searching, + key: ValueKey('idle'), + ), + ), label: Text( isScanning ? context.l10n.scanner_stop @@ -189,51 +218,6 @@ class _ScannerScreenState extends State { } } - Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) { - String statusText; - Color statusColor; - - final l10n = context.l10n; - switch (connector.state) { - case MeshCoreConnectionState.scanning: - statusText = l10n.scanner_scanning; - statusColor = Colors.blue; - break; - case MeshCoreConnectionState.connecting: - statusText = l10n.scanner_connecting; - statusColor = Colors.orange; - break; - case MeshCoreConnectionState.connected: - statusText = l10n.scanner_connectedTo(connector.deviceDisplayName); - statusColor = Colors.green; - break; - case MeshCoreConnectionState.disconnecting: - statusText = l10n.scanner_disconnecting; - statusColor = Colors.orange; - break; - case MeshCoreConnectionState.disconnected: - statusText = l10n.scanner_notConnected; - statusColor = Colors.grey; - break; - } - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - color: statusColor.withValues(alpha: 0.1), - child: Row( - children: [ - Icon(Icons.circle, size: 12, color: statusColor), - const SizedBox(width: 8), - Text( - statusText, - style: TextStyle(color: statusColor, fontWeight: FontWeight.w500), - ), - ], - ), - ); - } - Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) { if (connector.scanResults.isEmpty) { final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off; @@ -251,7 +235,10 @@ class _ScannerScreenState extends State { action: (isBluetoothOff || isScanning) ? null : FilledButton.icon( - onPressed: () => _toggleScan(connector), + onPressed: () { + HapticFeedback.lightImpact(); + _toggleScan(connector); + }, icon: const Icon(Icons.bluetooth_searching), label: Text(context.l10n.scanner_scan), ), @@ -259,19 +246,21 @@ class _ScannerScreenState extends State { } final isConnecting = connector.state == MeshCoreConnectionState.connecting; - return ListView.separated( - padding: const EdgeInsets.all(8), + return ListView.builder( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 96), itemCount: connector.scanResults.length, - separatorBuilder: (context, index) => const Divider(), itemBuilder: (context, index) { final result = connector.scanResults[index]; final deviceId = result.device.remoteId.toString(); - return DeviceTile( - scanResult: result, - isConnecting: isConnecting && _connectingDeviceId == deviceId, - onTap: isConnecting - ? null - : () => _connectToDevice(context, connector, result), + return ListEntrance( + index: index, + child: DeviceTile( + scanResult: result, + isConnecting: isConnecting && _connectingDeviceId == deviceId, + onTap: isConnecting + ? null + : () => _connectToDevice(context, connector, result), + ), ); }, ); @@ -413,47 +402,117 @@ class _ScannerScreenState extends State { ); return pin; } +} - Widget _bluetoothOffWarning(BuildContext context) { - final errorColor = Theme.of(context).colorScheme.error; - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - color: errorColor.withValues(alpha: 0.15), +// ── Private sub-widgets ──────────────────────────────────────────────────── + +/// Bluetooth-off warning banner — styled as an alert MeshCard. +class _BluetoothOffBanner extends StatelessWidget { + final VoidCallback? onEnable; + + const _BluetoothOffBanner({this.onEnable}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return MeshCard( + color: scheme.error.withValues(alpha: 0.08), + borderColor: scheme.error.withValues(alpha: 0.35), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), child: Row( children: [ - Icon(Icons.bluetooth_disabled, size: 24, color: errorColor), - const SizedBox(width: 12), + Icon(Icons.bluetooth_disabled, size: 20, color: scheme.error), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Text( context.l10n.scanner_bluetoothOff, style: TextStyle( - color: errorColor, + color: scheme.error, fontWeight: FontWeight.w600, - fontSize: 14, + fontSize: 13.5, ), ), - const SizedBox(height: 4), + const SizedBox(height: 2), Text( context.l10n.scanner_bluetoothOffMessage, style: TextStyle( - color: errorColor.withValues(alpha: 0.85), + color: scheme.error.withValues(alpha: 0.8), fontSize: 12, ), ), ], ), ), - if (PlatformInfo.isAndroid) + if (onEnable != null) ...[ + const SizedBox(width: 8), TextButton( - onPressed: () => FlutterBluePlus.turnOn(), + onPressed: onEnable, child: Text(context.l10n.scanner_enableBluetooth), ), + ], ], ), ); } } + +/// Connection status header with AnimatedSwitcher between states. +class _ConnectionStatusHeader extends StatelessWidget { + final MeshCoreConnector connector; + + const _ConnectionStatusHeader({required this.connector}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; + + final (String label, Color color, bool pulse) = switch (connector.state) { + MeshCoreConnectionState.scanning => ( + l10n.scanner_scanning, + MeshPalette.blue, + true, + ), + MeshCoreConnectionState.connecting => ( + l10n.scanner_connecting, + MeshPalette.warn, + true, + ), + MeshCoreConnectionState.connected => ( + l10n.scanner_connectedTo(connector.deviceDisplayName), + MeshPalette.signal, + false, + ), + MeshCoreConnectionState.disconnecting => ( + l10n.scanner_disconnecting, + MeshPalette.warn, + true, + ), + MeshCoreConnectionState.disconnected => ( + l10n.scanner_notConnected, + scheme.onSurfaceVariant, + false, + ), + }; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Align( + key: ValueKey(connector.state), + alignment: Alignment.centerLeft, + child: StatusChip( + label: label, + color: color, + pulse: pulse, + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index b6eefb05..3d12016a 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -10,8 +10,10 @@ import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/radio_settings.dart'; import '../services/app_debug_log_service.dart'; +import '../theme/mesh_theme.dart'; import '../widgets/app_bar.dart'; import '../helpers/snack_bar_builder.dart'; +import '../widgets/mesh_ui.dart'; import 'app_settings_screen.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; @@ -69,6 +71,7 @@ class _SettingsScreenState extends State { indicators: false, subtitle: false, ), + centerTitle: true, bottom: const SyncProgressAppBarBottom(), ), body: SafeArea( @@ -76,21 +79,81 @@ class _SettingsScreenState extends State { child: Consumer( builder: (context, connector, child) { return ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(0, 8, 0, 24), children: [ - _buildDeviceInfoCard(context, connector), - const SizedBox(height: 16), - _buildAppSettingsCard(context), - const SizedBox(height: 16), - _buildNodeSettingsCard(context, connector), - const SizedBox(height: 16), - _buildActionsCard(context, connector), - const SizedBox(height: 16), - _buildDebugCard(context), - const SizedBox(height: 16), - _buildExportCard(connector), - const SizedBox(height: 16), - _buildAboutCard(context), + // IDENTITY section + SectionHeader(l10n.settings_deviceInfo), + MeshCard( + padding: EdgeInsets.zero, + child: _buildIdentityCardContent(context, connector), + ), + + // NODE section + SectionHeader(l10n.settings_nodeSettings), + MeshCard( + padding: EdgeInsets.zero, + child: _buildNodeCardContent(context, connector), + ), + + // LOCATION section + SectionHeader(l10n.settings_location), + MeshCard( + padding: EdgeInsets.zero, + child: _buildLocationCardContent(context, connector), + ), + + // APP SETTINGS + SectionHeader(l10n.settings_appSettings), + MeshCard( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const AppSettingsScreen(), + ), + ), + child: _buildNavTileContent( + context, + icon: Icons.settings_outlined, + title: l10n.settings_appSettings, + subtitle: l10n.settings_appSettingsSubtitle, + ), + ), + + // ACTIONS section + SectionHeader(l10n.settings_actions), + MeshCard( + padding: EdgeInsets.zero, + child: _buildActionsCardContent(context, connector), + ), + + // EXPORT section + SectionHeader(l10n.settings_gpxExportRepeaters), + MeshCard( + padding: EdgeInsets.zero, + child: _buildExportCardContent(context, connector), + ), + + // DEBUG section + SectionHeader(l10n.settings_debug), + MeshCard( + padding: EdgeInsets.zero, + child: _buildDebugCardContent(context), + ), + + // ABOUT + SectionHeader(l10n.settings_about), + MeshCard( + onTap: () => _showAbout(context), + child: _buildNavTileContent( + context, + icon: Icons.info_outline, + title: l10n.settings_about, + subtitle: l10n.settings_aboutVersion( + _appVersion.isEmpty ? l10n.common_loading : _appVersion, + ), + showChevron: false, + ), + ), ], ); }, @@ -99,91 +162,217 @@ class _SettingsScreenState extends State { ); } - Widget _buildDeviceInfoCard( + Widget _buildNavTileContent( + BuildContext context, { + required IconData icon, + required String title, + required String subtitle, + bool showChevron = true, + }) { + final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Row( + children: [ + Icon(icon, size: 20, color: scheme.onSurfaceVariant), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (showChevron) + Icon( + Icons.chevron_right, + color: scheme.onSurfaceVariant, + size: 16, + ), + ], + ); + } + + Widget _buildIdentityCardContent( BuildContext context, MeshCoreConnector connector, ) { final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; - return Card( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: device name + status chip + expand toggle + InkWell( + onTap: () { + setState(() { + _deviceInfoExpanded = !_deviceInfoExpanded; + }); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + connector.deviceDisplayName, + style: MeshTheme.mono( + fontSize: 16, + fontWeight: FontWeight.w700, + color: scheme.onSurface, + ), + ), + const SizedBox(height: 4), + StatusChip( + label: connector.isConnected + ? l10n.common_connected + : l10n.common_disconnected, + color: connector.isConnected + ? MeshPalette.blue + : scheme.onSurfaceVariant, + pulse: connector.isConnected, + ), + ], + ), + ), + AnimatedRotation( + turns: _deviceInfoExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.expand_more, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + + // Expandable detail rows + AnimatedSize( + duration: const Duration(milliseconds: 200), + alignment: Alignment.topCenter, + child: _deviceInfoExpanded + ? Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1), + const SizedBox(height: 10), + _infoRow( + context, + label: l10n.settings_infoId, + value: connector.deviceIdLabel, + ), + _buildBatteryInfoRow(context, connector), + if (connector.selfName != null) + _infoRow( + context, + label: l10n.settings_nodeName, + value: connector.selfName!, + ), + if (connector.selfPublicKey != null) + _infoRow( + context, + label: l10n.settings_infoPublicKey, + value: + '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...', + mono: true, + ), + _infoRow( + context, + label: l10n.settings_infoContactsCount, + value: '${connector.contacts.length}', + ), + _infoRow( + context, + label: l10n.settings_infoChannelCount, + value: '${connector.channels.length}', + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } + + Widget _infoRow( + BuildContext context, { + required String label, + required String value, + bool mono = false, + Widget? leading, + Color? valueColor, + VoidCallback? onTap, + }) { + final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final content = Padding( + padding: const EdgeInsets.symmetric(vertical: 6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - setState(() { - _deviceInfoExpanded = !_deviceInfoExpanded; - }); - }, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), - child: Row( - children: [ - Expanded( - child: Text( - l10n.settings_deviceInfo, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - AnimatedRotation( - turns: _deviceInfoExpanded ? 0.5 : 0, - duration: const Duration(milliseconds: 200), - child: const Icon(Icons.expand_more), - ), - ], + Row( + children: [ + if (leading != null) ...[leading, const SizedBox(width: 6)], + Text( + label, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), ), - ), + ], ), - - AnimatedCrossFade( - firstChild: const SizedBox.shrink(), - secondChild: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildInfoRow( - l10n.settings_infoName, - connector.deviceDisplayName, + const SizedBox(height: 2), + mono + ? Text( + value, + style: MeshTheme.mono( + fontSize: 13, + fontWeight: FontWeight.w500, + color: valueColor ?? scheme.onSurface, ), - _buildInfoRow(l10n.settings_infoId, connector.deviceIdLabel), - _buildInfoRow( - l10n.settings_infoStatus, - connector.isConnected - ? l10n.common_connected - : l10n.common_disconnected, + ) + : Text( + value, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: valueColor, ), - _buildBatteryInfoRow(context, connector), - if (connector.selfName != null) - _buildInfoRow(l10n.settings_nodeName, connector.selfName!), - if (connector.selfPublicKey != null) - _buildInfoRow( - l10n.settings_infoPublicKey, - '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...', - ), - _buildInfoRow( - l10n.settings_infoContactsCount, - '${connector.contacts.length}', - ), - _buildInfoRow( - l10n.settings_infoChannelCount, - '${connector.channels.length}', - ), - ], - ), - ), - crossFadeState: _deviceInfoExpanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), - ), + ), ], ), ); + + if (onTap != null) { + return InkWell( + borderRadius: BorderRadius.circular(MeshRadii.xs), + onTap: onTap, + child: content, + ); + } + return content; } Widget _buildBatteryInfoRow( @@ -194,7 +383,6 @@ class _SettingsScreenState extends State { final percent = connector.batteryPercent; final millivolts = connector.batteryMillivolts; - // figure out display value final String displayValue; if (millivolts == null) { displayValue = l10n.common_notAvailable; @@ -226,10 +414,11 @@ class _SettingsScreenState extends State { valueColor = null; } - return _buildInfoRow( - l10n.settings_infoBattery, - displayValue, - leading: Icon(icon, size: 18, color: iconColor), + return _infoRow( + context, + label: l10n.settings_infoBattery, + value: displayValue, + leading: Icon(icon, size: 14, color: iconColor), valueColor: valueColor, onTap: millivolts != null ? () { @@ -241,260 +430,279 @@ class _SettingsScreenState extends State { ); } - Widget _buildAppSettingsCard(BuildContext context) { - final l10n = context.l10n; - return Card( - child: ListTile( - leading: const Icon(Icons.settings_outlined), - title: Text(l10n.settings_appSettings), - subtitle: Text(l10n.settings_appSettingsSubtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const AppSettingsScreen()), - ); - }, - ), - ); - } - - Widget _buildNodeSettingsCard( + Widget _buildNodeCardContent( BuildContext context, MeshCoreConnector connector, ) { final l10n = context.l10n; - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - l10n.settings_nodeSettings, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - ListTile( - leading: const Icon(Icons.person_outline), - title: Text(l10n.settings_nodeName), - subtitle: Text(connector.selfName ?? l10n.settings_nodeNameNotSet), - trailing: const Icon(Icons.chevron_right), - onTap: () => _editNodeName(context, connector), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.radio), - title: Text(l10n.settings_radioSettings), - subtitle: Text(l10n.settings_radioSettingsSubtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () => _showRadioSettings(context, connector), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.sensors_outlined), - title: Text(l10n.radioStats_settingsTile), - subtitle: Text(l10n.radioStats_settingsSubtitle), - trailing: const Icon(Icons.chevron_right), - enabled: - connector.isConnected && connector.supportsCompanionRadioStats, - onTap: () => pushCompanionRadioStatsScreen(context), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.location_on_outlined), - title: Text(l10n.settings_location), - subtitle: Text(l10n.settings_locationSubtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () => _editLocation(context, connector), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.group_add_outlined), - title: Text(l10n.settings_contactSettings), - subtitle: Text(l10n.settings_contactSettingsSubtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () => _editAutoAddConfig(context, connector), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.visibility_off_outlined), - title: Text(l10n.settings_privacy), - subtitle: Text(l10n.settings_privacySubtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () => _privacySettings(context, connector), - ), - ], - ), - ); - } - - Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) { - final l10n = context.l10n; - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - l10n.settings_actions, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - ListTile( - leading: Icon( - Icons.delete_outline, - color: Theme.of(context).colorScheme.error, - ), - title: Text(l10n.settings_deleteAllPaths), - subtitle: Text( - l10n.settings_deleteAllPathsSubtitle, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - onTap: () => _confirmDeleteAllPaths(context, connector), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.sync), - title: Text(l10n.settings_syncTime), - subtitle: Text(l10n.settings_syncTimeSubtitle), - onTap: () => _syncTime(context, connector), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.refresh), - title: Text(l10n.settings_refreshContacts), - subtitle: Text(l10n.settings_refreshContactsSubtitle), - onTap: () => connector.getContacts(), - ), - const Divider(height: 1), - ListTile( - leading: Icon( - Icons.restart_alt, - color: Theme.of(context).colorScheme.tertiary, - ), - title: Text(l10n.settings_rebootDevice), - subtitle: Text(l10n.settings_rebootDeviceSubtitle), - onTap: () => _confirmReboot(context, connector), - ), - ], - ), - ); - } - - Widget _buildAboutCard(BuildContext context) { - final l10n = context.l10n; - return Card( - child: ListTile( - leading: const Icon(Icons.info_outline), - title: Text(l10n.settings_about), - subtitle: Text( - l10n.settings_aboutVersion( - _appVersion.isEmpty ? l10n.common_loading : _appVersion, - ), + return Column( + children: [ + _tappableTile( + context, + icon: Icons.person_outline, + title: l10n.settings_nodeName, + subtitle: connector.selfName ?? l10n.settings_nodeNameNotSet, + onTap: () => _editNodeName(context, connector), ), - onTap: () => _showAbout(context), - ), + const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.radio, + title: l10n.settings_radioSettings, + subtitle: l10n.settings_radioSettingsSubtitle, + onTap: () => _showRadioSettings(context, connector), + ), + const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.sensors_outlined, + title: l10n.radioStats_settingsTile, + subtitle: l10n.radioStats_settingsSubtitle, + onTap: connector.isConnected && connector.supportsCompanionRadioStats + ? () => pushCompanionRadioStatsScreen(context) + : null, + ), + ], ); } - Widget _buildDebugCard(BuildContext context) { + Widget _buildLocationCardContent( + BuildContext context, + MeshCoreConnector connector, + ) { final l10n = context.l10n; - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - l10n.settings_debug, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - ListTile( - leading: const Icon(Icons.bluetooth_outlined), - title: Text(l10n.settings_companionDebugLog), - subtitle: Text(l10n.settings_companionDebugLogSubtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const BleDebugLogScreen(), - ), - ); - }, - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.code_outlined), - title: Text(l10n.settings_appDebugLog), - subtitle: Text(l10n.settings_appDebugLogSubtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const AppDebugLogScreen(), - ), - ); - }, - ), - ], - ), + return Column( + children: [ + _tappableTile( + context, + icon: Icons.location_on_outlined, + title: l10n.settings_location, + subtitle: l10n.settings_locationSubtitle, + onTap: () => _editLocation(context, connector), + ), + const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.group_add_outlined, + title: l10n.settings_contactSettings, + subtitle: l10n.settings_contactSettingsSubtitle, + onTap: () => _editAutoAddConfig(context, connector), + ), + const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.visibility_off_outlined, + title: l10n.settings_privacy, + subtitle: l10n.settings_privacySubtitle, + onTap: () => _privacySettings(context, connector), + ), + ], ); } - Widget _buildInfoRow( - String label, - String value, { - Widget? leading, - Color? valueColor, - VoidCallback? onTap, - }) { - final theme = Theme.of(context); + Widget _buildActionsCardContent( + BuildContext context, + MeshCoreConnector connector, + ) { + final l10n = context.l10n; + return Column( + children: [ + _tappableTile( + context, + icon: Icons.sync, + title: l10n.settings_syncTime, + subtitle: l10n.settings_syncTimeSubtitle, + onTap: () => _syncTime(context, connector), + ), + const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.refresh, + title: l10n.settings_refreshContacts, + subtitle: l10n.settings_refreshContactsSubtitle, + onTap: () => connector.getContacts(), + ), + const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.restart_alt, + title: l10n.settings_rebootDevice, + subtitle: l10n.settings_rebootDeviceSubtitle, + titleColor: MeshPalette.warn, + iconColor: MeshPalette.warn, + onTap: () => _confirmReboot(context, connector), + ), + const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.delete_outline, + title: l10n.settings_deleteAllPaths, + subtitle: l10n.settings_deleteAllPathsSubtitle, + titleColor: MeshPalette.alert, + iconColor: MeshPalette.alert, + onTap: () => _confirmDeleteAllPaths(context, connector), + ), + ], + ); + } - final row = Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (leading != null) ...[leading, const SizedBox(width: 8)], - Expanded( - child: Text( - label, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), + Widget _buildExportCardContent( + BuildContext context, + MeshCoreConnector connector, + ) { + final l10n = context.l10n; + return Column( + children: [ + _tappableTile( + context, + icon: Icons.download_outlined, + title: l10n.settings_gpxExportRepeaters, + subtitle: l10n.settings_gpxExportRepeatersSubtitle, + onTap: () async { + final exporter = GpxExport(connector); + exporter.addRepeaters(); + _gpxExport( + exporter, + l10n.map_repeater, + l10n.settings_gpxExportRepeatersRoom, + 'meshcore_repeaters_', + l10n.settings_gpxExportShareText, + l10n.settings_gpxExportShareSubject, + ); + }, + ), + const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.download_outlined, + title: l10n.settings_gpxExportContacts, + subtitle: l10n.settings_gpxExportContactsSubtitle, + onTap: () async { + final exporter = GpxExport(connector); + exporter.addContacts(); + _gpxExport( + exporter, + l10n.map_repeater, + l10n.settings_gpxExportChat, + 'meshcore_contacts_', + l10n.settings_gpxExportShareText, + l10n.settings_gpxExportShareSubject, + ); + }, + ), + const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.download_outlined, + title: l10n.settings_gpxExportAll, + subtitle: l10n.settings_gpxExportAllSubtitle, + onTap: () async { + final exporter = GpxExport(connector); + exporter.addAll(); + _gpxExport( + exporter, + l10n.map_repeater, + l10n.settings_gpxExportAllContacts, + 'meshcore_all_', + l10n.settings_gpxExportShareText, + l10n.settings_gpxExportShareSubject, + ); + }, + ), + ], + ); + } + + Widget _buildDebugCardContent(BuildContext context) { + final l10n = context.l10n; + return Column( + children: [ + _tappableTile( + context, + icon: Icons.bluetooth_outlined, + title: l10n.settings_companionDebugLog, + subtitle: l10n.settings_companionDebugLogSubtitle, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const BleDebugLogScreen(), + ), + ); + }, + ), + const Divider(height: 1, indent: 16), + _tappableTile( + context, + icon: Icons.code_outlined, + title: l10n.settings_appDebugLog, + subtitle: l10n.settings_appDebugLogSubtitle, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AppDebugLogScreen(), + ), + ); + }, + ), + ], + ); + } + + Widget _tappableTile( + BuildContext context, { + required IconData icon, + required String title, + required String subtitle, + VoidCallback? onTap, + Color? titleColor, + Color? iconColor, + }) { + final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final effectiveIconColor = iconColor ?? scheme.onSurfaceVariant; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, size: 20, color: effectiveIconColor), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: titleColor, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: textTheme.bodySmall?.copyWith( + color: titleColor != null + ? titleColor.withValues(alpha: 0.7) + : scheme.onSurfaceVariant, + ), + ), + ], ), - ], - ), - const SizedBox(height: 4), - Text( - value, - style: theme.textTheme.bodyLarge?.copyWith( - color: valueColor, - fontWeight: FontWeight.w500, ), - ), - ], + Icon( + Icons.chevron_right, + color: scheme.onSurfaceVariant, + size: 16, + ), + ], + ), ), ); - - if (onTap != null) { - return InkWell( - borderRadius: BorderRadius.circular(6), - onTap: onTap, - child: row, - ); - } - - return row; } void _editNodeName(BuildContext context, MeshCoreConnector connector) { @@ -837,70 +1045,6 @@ class _SettingsScreenState extends State { } } - Widget _buildExportCard(MeshCoreConnector connector) { - final l10n = context.l10n; - return Card( - child: Column( - children: [ - ListTile( - leading: const Icon(Icons.download_outlined), - title: Text(l10n.settings_gpxExportRepeaters), - subtitle: Text(l10n.settings_gpxExportRepeatersSubtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - final exporter = GpxExport(connector); - exporter.addRepeaters(); - _gpxExport( - exporter, - l10n.map_repeater, - l10n.settings_gpxExportRepeatersRoom, - "meshcore_repeaters_", - l10n.settings_gpxExportShareText, - l10n.settings_gpxExportShareSubject, - ); - }, - ), - ListTile( - leading: const Icon(Icons.download_outlined), - title: Text(l10n.settings_gpxExportContacts), - subtitle: Text(l10n.settings_gpxExportContactsSubtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - final exporter = GpxExport(connector); - exporter.addContacts(); - _gpxExport( - exporter, - l10n.map_repeater, - l10n.settings_gpxExportChat, - "meshcore_contacts_", - l10n.settings_gpxExportShareText, - l10n.settings_gpxExportShareSubject, - ); - }, - ), - ListTile( - leading: const Icon(Icons.download_outlined), - title: Text(l10n.settings_gpxExportAll), - subtitle: Text(l10n.settings_gpxExportAllSubtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - final exporter = GpxExport(connector); - exporter.addAll(); - _gpxExport( - exporter, - l10n.map_repeater, - l10n.settings_gpxExportAllContacts, - "meshcore_all_", - l10n.settings_gpxExportShareText, - l10n.settings_gpxExportShareSubject, - ); - }, - ), - ], - ), - ); - } - void _editAutoAddConfig(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; bool autoAddChat = false; @@ -933,7 +1077,7 @@ class _SettingsScreenState extends State { setDialogState(() => autoAddChat = value); }, ), - SizedBox(height: 8), + const SizedBox(height: 8), FeatureToggleRow( title: l10n.contactsSettings_autoAddRepeatersTitle, subtitle: l10n.contactsSettings_autoAddRepeatersSubtitle, @@ -942,7 +1086,7 @@ class _SettingsScreenState extends State { setDialogState(() => autoAddRepeater = value); }, ), - SizedBox(height: 8), + const SizedBox(height: 8), FeatureToggleRow( title: l10n.contactsSettings_autoAddRoomServersTitle, subtitle: l10n.contactsSettings_autoAddRoomServersSubtitle, @@ -951,7 +1095,7 @@ class _SettingsScreenState extends State { setDialogState(() => autoAddRoomServer = value); }, ), - SizedBox(height: 8), + const SizedBox(height: 8), FeatureToggleRow( title: l10n.contactsSettings_autoAddSensorsTitle, subtitle: l10n.contactsSettings_autoAddSensorsSubtitle, @@ -960,7 +1104,7 @@ class _SettingsScreenState extends State { setDialogState(() => autoAddSensor = value); }, ), - Divider(height: 4), + const Divider(height: 4), FeatureToggleRow( title: l10n.contactsSettings_overwriteOldestTitle, subtitle: l10n.contactsSettings_overwriteOldestSubtitle, diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index 517b39a6..cbed0277 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -1,13 +1,16 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../services/app_settings_service.dart'; +import '../theme/mesh_theme.dart'; import '../utils/platform_info.dart'; import '../widgets/adaptive_app_bar_title.dart'; +import '../widgets/mesh_ui.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; import 'usb_screen.dart'; @@ -95,15 +98,32 @@ class _TcpScreenState extends State { final isConnecting = connector.state == MeshCoreConnectionState.connecting && connector.activeTransport == MeshCoreTransportType.tcp; - // A running BLE scan must not block TCP connect: connectTcp() stops - // any active scan before connecting, so the only reason to disable - // the button is a TCP connect already in flight. - final isButtonDisabled = isConnecting; - return Column( + // Connect is only available from a fully disconnected state — + // scanning, connecting, or an active session must settle first. + final isButtonDisabled = + connector.state != MeshCoreConnectionState.disconnected; + return ListView( + padding: const EdgeInsets.only(bottom: 32), children: [ - _buildStatusBar(context, connector), - _buildTransportLinks(context), + // Status header Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Align( + key: ValueKey(connector.state), + alignment: Alignment.centerLeft, + child: _buildStatusChip(context, connector), + ), + ), + ), + + // Transport switcher + _buildTransportLinks(context), + + // Connection form + const SectionHeader('TCP / IP'), + MeshCard( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -113,7 +133,6 @@ class _TcpScreenState extends State { decoration: InputDecoration( labelText: context.l10n.tcpHostLabel, hintText: context.l10n.tcpHostHint, - border: const OutlineInputBorder(), ), enabled: !isConnecting, keyboardType: TextInputType.url, @@ -124,7 +143,6 @@ class _TcpScreenState extends State { decoration: InputDecoration( labelText: context.l10n.tcpPortLabel, hintText: context.l10n.tcpPortHint, - border: const OutlineInputBorder(), ), enabled: !isConnecting, keyboardType: TextInputType.number, @@ -132,7 +150,12 @@ class _TcpScreenState extends State { const SizedBox(height: 16), FilledButton.icon( key: const Key('tcp_connect_button'), - onPressed: isButtonDisabled ? null : _connectTcp, + onPressed: isButtonDisabled + ? null + : () { + HapticFeedback.lightImpact(); + _connectTcp(); + }, icon: isConnecting ? const SizedBox( width: 18, @@ -151,6 +174,39 @@ class _TcpScreenState extends State { ], ), ), + + // Last used endpoint + if (connector.activeTcpEndpoint != null && + connector.isTcpTransportConnected) ...[ + const SectionHeader('CONNECTED TO'), + MeshCard( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + child: Row( + children: [ + Icon( + Icons.lan, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + connector.activeTcpEndpoint!, + style: MeshTheme.mono( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], ], ); }, @@ -159,6 +215,40 @@ class _TcpScreenState extends State { ); } + Widget _buildStatusChip(BuildContext context, MeshCoreConnector connector) { + final l10n = context.l10n; + + if (connector.isTcpTransportConnected) { + return StatusChip( + label: l10n.scanner_connectedTo( + connector.activeTcpEndpoint ?? 'TCP', + ), + color: MeshPalette.signal, + ); + } else if (connector.state == MeshCoreConnectionState.connecting && + connector.activeTransport == MeshCoreTransportType.tcp) { + return StatusChip( + label: l10n.tcpStatus_connectingTo( + '${_hostController.text}:${_portController.text}', + ), + color: MeshPalette.warn, + pulse: true, + ); + } else if (connector.state == MeshCoreConnectionState.disconnecting && + connector.activeTransport == MeshCoreTransportType.tcp) { + return StatusChip( + label: l10n.scanner_disconnecting, + color: MeshPalette.warn, + pulse: true, + ); + } else { + return StatusChip( + label: l10n.tcpStatus_notConnected, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + } + } + Widget _buildTransportLinks(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -186,52 +276,6 @@ class _TcpScreenState extends State { ); } - Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) { - final l10n = context.l10n; - String statusText; - Color statusColor; - - if (connector.isTcpTransportConnected) { - statusText = l10n.scanner_connectedTo( - connector.activeTcpEndpoint ?? 'TCP', - ); - statusColor = Colors.green; - } else if (connector.state == MeshCoreConnectionState.connecting && - connector.activeTransport == MeshCoreTransportType.tcp) { - statusText = l10n.tcpStatus_connectingTo( - '${_hostController.text}:${_portController.text}', - ); - statusColor = Colors.orange; - } else if (connector.state == MeshCoreConnectionState.disconnecting && - connector.activeTransport == MeshCoreTransportType.tcp) { - statusText = l10n.scanner_disconnecting; - statusColor = Colors.orange; - } else { - statusText = l10n.tcpStatus_notConnected; - statusColor = Theme.of(context).colorScheme.onSurfaceVariant; - } - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - color: statusColor.withValues(alpha: 0.1), - child: Row( - children: [ - Icon(Icons.circle, size: 12, color: statusColor), - const SizedBox(width: 8), - Expanded( - child: Text( - statusText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: statusColor, fontWeight: FontWeight.w500), - ), - ), - ], - ), - ); - } - Future _connectTcp() async { if (_connector.state == MeshCoreConnectionState.connecting || _connector.state == MeshCoreConnectionState.connected || diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart index 4559adac..7087bd2c 100644 --- a/lib/screens/telemetry_screen.dart +++ b/lib/screens/telemetry_screen.dart @@ -19,6 +19,8 @@ import '../utils/battery_utils.dart'; import '../helpers/snack_bar_builder.dart'; import '../widgets/sync_progress_overlay.dart'; import '../widgets/telemetry_location_map.dart'; +import '../theme/mesh_theme.dart'; +import '../widgets/mesh_ui.dart'; class TelemetryScreen extends StatefulWidget { final Contact contact; @@ -319,6 +321,7 @@ class _TelemetryScreenState extends State { @override Widget build(BuildContext context) { final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; final connector = context.watch(); final settings = context.watch().settings; final isImperialUnits = settings.unitSystem == UnitSystem.imperial; @@ -387,7 +390,7 @@ class _TelemetryScreenState extends State { l10n.telemetry_noData, style: TextStyle( fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: scheme.onSurfaceVariant, ), ), ), @@ -415,34 +418,21 @@ class _TelemetryScreenState extends State { int channel, bool isImperialUnits, ) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.info_outline, - color: Theme.of(context).textTheme.headlineSmall?.color, - ), - const SizedBox(width: 8), - Text( - title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const Divider(), - for (final entry in channelData.entries) - _buildTelemetryField(entry, channel, isImperialUnits), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader(title, padding: const EdgeInsets.fromLTRB(16, 16, 16, 8)), + MeshCard( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final entry in channelData.entries) + _buildTelemetryField(entry, channel, isImperialUnits), + ], + ), ), - ), + ], ); } @@ -601,30 +591,19 @@ class _TelemetryScreenState extends State { final l10n = context.l10n; final counterText = _autoRefreshCounterText(); - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Icon( - Icons.autorenew, - color: Theme.of(context).textTheme.headlineSmall?.color, - ), - const SizedBox(width: 8), - Text( - l10n.common_autoRefresh, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const Divider(), - _buildAutoRefreshNumberField( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader( + l10n.common_autoRefresh, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + ), + MeshCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildAutoRefreshNumberField( controller: _autoRefreshIntervalController, label: l10n.common_interval, min: _autoRefreshMinIntervalSeconds, @@ -684,6 +663,7 @@ class _TelemetryScreenState extends State { ], ), ), + ], ); } @@ -913,6 +893,7 @@ class _TelemetryScreenState extends State { } Widget _buildInfoRow(String label, String value) { + final scheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( @@ -922,7 +903,8 @@ class _TelemetryScreenState extends State { child: Text( label, style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: scheme.onSurfaceVariant, + fontSize: 13, fontWeight: FontWeight.w500, ), ), @@ -930,7 +912,10 @@ class _TelemetryScreenState extends State { const SizedBox(width: 8), Text( value, - style: const TextStyle(fontWeight: FontWeight.w400), + style: MeshTheme.mono( + fontSize: 13, + color: scheme.onSurface, + ), textAlign: TextAlign.end, ), ], diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart index ec551c6f..5b42767b 100644 --- a/lib/screens/usb_screen.dart +++ b/lib/screens/usb_screen.dart @@ -6,10 +6,13 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; +import '../theme/mesh_theme.dart'; import '../utils/app_logger.dart'; import '../utils/platform_info.dart'; import '../utils/usb_port_labels.dart'; import '../widgets/adaptive_app_bar_title.dart'; +import '../widgets/empty_state.dart'; +import '../widgets/mesh_ui.dart'; import '../helpers/snack_bar_builder.dart'; import 'channels_screen.dart'; import 'tcp_screen.dart'; @@ -97,9 +100,27 @@ class _UsbScreenState extends State { child: Consumer( builder: (context, connector, child) { return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildStatusBar(context, connector), + // Status header + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Align( + key: ValueKey( + '${connector.state}_$_isLoadingPorts', + ), + alignment: Alignment.centerLeft, + child: _buildStatusChip(context, connector), + ), + ), + ), + + // Transport switcher _buildTransportLinks(context), + + // Port list Expanded(child: _buildPortList(context, connector)), ], ); @@ -132,6 +153,52 @@ class _UsbScreenState extends State { ); } + Widget _buildStatusChip(BuildContext context, MeshCoreConnector connector) { + final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; + + if (_isLoadingPorts) { + return StatusChip( + label: l10n.usbStatus_searching, + color: scheme.primary, + pulse: true, + ); + } else if (connector.isUsbTransportConnected) { + switch (connector.state) { + case MeshCoreConnectionState.connected: + return StatusChip( + label: l10n.scanner_connectedTo( + connector.activeUsbPortDisplayLabel ?? 'USB', + ), + color: MeshPalette.signal, + ); + case MeshCoreConnectionState.disconnecting: + return StatusChip( + label: l10n.scanner_disconnecting, + color: MeshPalette.warn, + pulse: true, + ); + default: + return StatusChip( + label: l10n.usbStatus_notConnected, + color: scheme.onSurfaceVariant, + ); + } + } else if (connector.state == MeshCoreConnectionState.connecting && + connector.activeTransport == MeshCoreTransportType.usb) { + return StatusChip( + label: l10n.usbStatus_connecting, + color: MeshPalette.warn, + pulse: true, + ); + } else { + return StatusChip( + label: l10n.usbStatus_notConnected, + color: scheme.onSurfaceVariant, + ); + } + } + Widget _buildTransportLinks(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -159,105 +226,20 @@ class _UsbScreenState extends State { ); } - Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) { - final l10n = context.l10n; - String statusText; - Color statusColor; - - if (_isLoadingPorts) { - statusText = l10n.usbStatus_searching; - statusColor = Theme.of(context).colorScheme.primary; - } else if (connector.isUsbTransportConnected) { - switch (connector.state) { - case MeshCoreConnectionState.connected: - statusText = l10n.scanner_connectedTo( - connector.activeUsbPortDisplayLabel ?? 'USB', - ); - statusColor = Colors.green; - case MeshCoreConnectionState.disconnecting: - statusText = l10n.scanner_disconnecting; - statusColor = Colors.orange; - default: - statusText = l10n.usbStatus_notConnected; - statusColor = Theme.of(context).colorScheme.onSurfaceVariant; - } - } else if (connector.state == MeshCoreConnectionState.connecting && - connector.activeTransport == MeshCoreTransportType.usb) { - statusText = l10n.usbStatus_connecting; - statusColor = Colors.orange; - } else { - statusText = l10n.usbStatus_notConnected; - statusColor = Theme.of(context).colorScheme.onSurfaceVariant; - } - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - color: statusColor.withValues(alpha: 0.1), - child: Row( - children: [ - Icon(Icons.circle, size: 12, color: statusColor), - const SizedBox(width: 8), - Expanded( - child: Text( - statusText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: statusColor, fontWeight: FontWeight.w500), - ), - ), - ], - ), - ); - } - Widget _buildPortList(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; if (_isLoadingPorts) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.usb, - size: 64, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - l10n.usbStatus_searching, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), + return EmptyState( + icon: Icons.usb, + title: l10n.usbStatus_searching, ); } if (_ports.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.usb, - size: 64, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - l10n.usbScreenEmptyState, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), + return EmptyState( + icon: Icons.usb, + title: l10n.usbScreenEmptyState, ); } @@ -265,10 +247,9 @@ class _UsbScreenState extends State { connector.state == MeshCoreConnectionState.connecting && connector.activeTransport == MeshCoreTransportType.usb; - return ListView.separated( - padding: const EdgeInsets.all(8), + return ListView.builder( + padding: const EdgeInsets.only(bottom: 32), itemCount: _ports.length, - separatorBuilder: (context, index) => const Divider(), itemBuilder: (context, index) { final port = _ports[index]; final displayName = friendlyUsbPortName(port); @@ -276,15 +257,50 @@ class _UsbScreenState extends State { final showRawName = rawName != displayName && !rawName.startsWith('web:'); - return ListTile( - leading: const Icon(Icons.usb), - title: Text( - displayName, - style: const TextStyle(fontWeight: FontWeight.w500), + return ListEntrance( + index: index, + child: MeshCard( + padding: EdgeInsets.zero, + child: ListTile( + onTap: isConnecting + ? null + : () { + HapticFeedback.selectionClick(); + _connectPort(port); + }, + leading: AvatarCircle( + name: displayName, + size: 40, + icon: Icons.usb, + color: Theme.of(context).colorScheme.primary, + ), + title: Text( + displayName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: showRawName + ? Text( + rawName, + style: MeshTheme.mono( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: Icon( + Icons.chevron_right, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ), - subtitle: showRawName ? Text(rawName) : null, - trailing: const Icon(Icons.chevron_right), - onTap: isConnecting ? null : () => _connectPort(port), ); }, ); diff --git a/lib/theme/mesh_theme.dart b/lib/theme/mesh_theme.dart index 00b92ec0..fcb15b1b 100644 --- a/lib/theme/mesh_theme.dart +++ b/lib/theme/mesh_theme.dart @@ -1,47 +1,48 @@ +import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder; import 'package:flutter/material.dart'; -/// MeshCore palette — cool slate dark theme with sky-blue accents. +/// MeshCore palette — high-contrast slate surfaces with sky-blue accents. class MeshPalette { MeshPalette._(); - // Surfaces (cool near-black, slate undertone) - static const bg = Color(0xFF101417); - static const bg1 = Color(0xFF161B1F); - static const bg2 = Color(0xFF1D242A); - static const bg3 = Color(0xFF28313A); - static const bg4 = Color(0xFF344049); + // Surfaces shared with the map overlays and navigation. + static const bg = Color(0xFF0B1220); + static const bg1 = Color(0xFF0F172A); + static const bg2 = Color(0xFF162033); + static const bg3 = Color(0xFF1E293B); + static const bg4 = Color(0xFF334155); // Lines - static const line = Color(0xFF222B31); - static const line2 = Color(0xFF344049); - static const line3 = Color(0xFF485762); + static const line = Color(0xFF1E293B); + static const line2 = Color(0xFF334155); + static const line3 = Color(0xFF475569); // Ink - static const ink = Color(0xFFE9EEF3); - static const ink2 = Color(0xFFB5C0C9); - static const ink3 = Color(0xFF7C8A95); - static const ink4 = Color(0xFF556470); + static const ink = Color(0xFFF8FAFC); + static const ink2 = Color(0xFFCBD5E1); + static const ink3 = Color(0xFF94A3B8); + static const ink4 = Color(0xFF64748B); // Signal-quality green (used only for SNR coloring, not UI chrome) - static const signal = Color(0xFF7BEFA8); - static const signalDim = Color(0xFF4DC580); + static const signal = Color(0xFF22C55E); + static const signalDim = Color(0xFF16A34A); - // Warn (ember) - static const warn = Color(0xFFFFA552); - static const warnDim = Color(0xFFC27E3C); - static const warnBg = Color(0x1CFFA552); - static const warnLine = Color(0x4DFFA552); + // Warn + static const warn = Color(0xFFF59E0B); + static const warnDim = Color(0xFFD97706); + static const warnBg = Color(0x1FF59E0B); + static const warnLine = Color(0x66F59E0B); - // Alert (coral) - static const alert = Color(0xFFFF6A5C); - static const alertBg = Color(0x1CFF6A5C); - static const alertLine = Color(0x52FF6A5C); + // Alert + static const alert = Color(0xFFEF4444); + static const alertBg = Color(0x1FEF4444); + static const alertLine = Color(0x66EF4444); - // Blue (sky) — primary accent - static const blue = Color(0xFF7FCBF5); - static const blueDim = Color(0xFF4A9CC9); - static const blueBg = Color(0x1C7FCBF5); - static const blueLine = Color(0x477FCBF5); + // Blue — primary map/app accent + static const blue = Color(0xFF0EA5E9); + static const blueDim = Color(0xFF0284C7); + static const blueBg = Color(0x290EA5E9); + static const blueLine = Color(0x800EA5E9); // Magenta static const magenta = Color(0xFFDE7FDB); @@ -49,9 +50,9 @@ class MeshPalette { static const magentaLine = Color(0x47DE7FDB); // Me bubble (dusk blue) - static const me = Color(0xFF1B2C3D); - static const meBorder = Color(0xFF2C4A66); - static const meInk = Color(0xFFDCE9F5); + static const me = Color(0xFF0C4A6E); + static const meBorder = Color(0xFF0369A1); + static const meInk = Color(0xFFF0F9FF); // ── Light variant (used when user explicitly picks light theme) static const lightBg = Color(0xFFF4F6F8); @@ -64,6 +65,51 @@ class MeshPalette { static const lightBlue = Color(0xFF2F6EA8); } +/// High-contrast semantic colors for UI rendered over variable map tiles. +class MapPalette { + MapPalette._(); + + static const online = Color(0xFF22C55E); + static const offline = Color(0xFF6B7280); + static const stale = Color(0xFFF59E0B); + static const repeater = Color(0xFF2563EB); + static const router = Color(0xFF7C3AED); + static const batteryLow = Color(0xFFEF4444); + static const cluster = Color(0xFFF97316); + static const selected = Color(0xFF0EA5E9); + static const sensor = Color(0xFF0F766E); + static const shared = Color(0xFF0369A1); + + static const panelLight = Color(0xF0FFFFFF); + static const panelDark = Color(0xF50B1220); + static const textPrimary = Color(0xFFF8FAFC); + static const textSecondary = Color(0xFFCBD5E1); + static const textMuted = Color(0xFF94A3B8); + static const border = Color(0x5264758B); + static const markerOutline = Colors.white; + static const markerShadow = Color(0xB3000000); +} + +/// High-contrast colors for line-of-sight maps and elevation profiles. +class LosPalette { + LosPalette._(); + + static const terrain = Color(0xFFA3E635); + static const beam = Color(0xFF38BDF8); + static const horizon = Color(0xFFFBBF24); + static const blocked = Color(0xFFEF4444); + static const marginal = Color(0xFFF59E0B); + static const clear = Color(0xFF22C55E); + static const selected = Color(0xFF0EA5E9); + static const chartBackground = Color(0xFF0B1220); + static const panelDark = Color(0xF00F172A); + static const panelLight = Color(0xF5FFFFFF); + static const text = Color(0xFFF8FAFC); + static const textMuted = Color(0xFFCBD5E1); + static const border = Color(0x5264758B); + static const shadow = Color(0x99000000); +} + /// Named font stacks — Flutter falls back to system fonts when the named /// family isn't installed, keeping things working without bundled assets. class MeshFonts { @@ -72,6 +118,7 @@ class MeshFonts { static const sans = 'Inter'; static const mono = 'JetBrains Mono'; static const display = 'Instrument Serif'; + static const emoji = 'Noto Color Emoji'; static const List sansFallback = [ 'system-ui', @@ -93,6 +140,11 @@ class MeshFonts { 'Times New Roman', 'serif', ]; + static const List emojiFallback = [ + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Noto Emoji', + ]; } /// Radii used consistently across the app. @@ -113,21 +165,21 @@ class MeshTheme { static ThemeData dark() { const scheme = ColorScheme.dark( primary: MeshPalette.blue, - onPrimary: Color(0xFF0A1A26), - primaryContainer: MeshPalette.blueBg, - onPrimaryContainer: MeshPalette.blue, + onPrimary: Colors.white, + primaryContainer: Color(0xFF075985), + onPrimaryContainer: Colors.white, secondary: MeshPalette.magenta, - onSecondary: Color(0xFF201020), + onSecondary: Colors.white, secondaryContainer: Color(0xFF331A33), - onSecondaryContainer: MeshPalette.magenta, + onSecondaryContainer: Colors.white, tertiary: MeshPalette.warn, - onTertiary: Color(0xFF1F1206), - tertiaryContainer: Color(0xFF3A2710), - onTertiaryContainer: Color(0xFFFFC58A), + onTertiary: Color(0xFF0B1220), + tertiaryContainer: Color(0xFF78350F), + onTertiaryContainer: Colors.white, error: MeshPalette.alert, - onError: Color(0xFF1A0A08), - errorContainer: MeshPalette.alertBg, - onErrorContainer: MeshPalette.alert, + onError: Colors.white, + errorContainer: Color(0xFF7F1D1D), + onErrorContainer: Colors.white, surface: MeshPalette.bg, onSurface: MeshPalette.ink, surfaceContainerLowest: MeshPalette.bg, @@ -334,9 +386,9 @@ class MeshTheme { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), ), navigationBarTheme: NavigationBarThemeData( - backgroundColor: scheme.surfaceContainerLow, + backgroundColor: scheme.surface, surfaceTintColor: Colors.transparent, - indicatorColor: scheme.primary.withValues(alpha: 0.14), + indicatorColor: scheme.primary, indicatorShape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(MeshRadii.md), ), @@ -348,13 +400,13 @@ class MeshTheme { fontSize: 10, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, letterSpacing: 0.1, - color: selected ? scheme.primary : scheme.onSurfaceVariant, + color: selected ? scheme.onPrimary : scheme.onSurfaceVariant, ); }), iconTheme: WidgetStateProperty.resolveWith((states) { final selected = states.contains(WidgetState.selected); return IconThemeData( - color: selected ? scheme.primary : scheme.onSurfaceVariant, + color: selected ? scheme.onPrimary : scheme.onSurfaceVariant, size: 22, ); }), @@ -393,6 +445,106 @@ class MeshTheme { ), iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 22), splashFactory: InkSparkle.splashFactory, + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.linux: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.macOS: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.windows: FadeForwardsPageTransitionsBuilder(), + }, + ), + segmentedButtonTheme: SegmentedButtonThemeData( + style: SegmentedButton.styleFrom( + selectedBackgroundColor: scheme.primary.withValues(alpha: 0.16), + selectedForegroundColor: scheme.primary, + side: BorderSide(color: scheme.outlineVariant), + textStyle: const TextStyle( + fontFamily: MeshFonts.sans, + fontFamilyFallback: MeshFonts.sansFallback, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? scheme.onPrimary + : scheme.onSurfaceVariant, + ), + trackColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? scheme.primary + : scheme.surfaceContainerHighest, + ), + trackOutlineColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? Colors.transparent + : scheme.outline, + ), + ), + sliderTheme: SliderThemeData( + activeTrackColor: scheme.primary, + inactiveTrackColor: scheme.surfaceContainerHighest, + thumbColor: scheme.primary, + overlayColor: scheme.primary.withValues(alpha: 0.12), + valueIndicatorColor: scheme.surfaceContainerHighest, + valueIndicatorTextStyle: TextStyle( + fontFamily: MeshFonts.mono, + fontFamilyFallback: MeshFonts.monoFallback, + color: scheme.onSurface, + fontSize: 12, + ), + trackHeight: 3, + ), + tabBarTheme: TabBarThemeData( + labelColor: scheme.primary, + unselectedLabelColor: scheme.onSurfaceVariant, + indicatorColor: scheme.primary, + dividerColor: scheme.outlineVariant, + labelStyle: const TextStyle( + fontFamily: MeshFonts.sans, + fontFamilyFallback: MeshFonts.sansFallback, + fontSize: 13.5, + fontWeight: FontWeight.w700, + ), + unselectedLabelStyle: const TextStyle( + fontFamily: MeshFonts.sans, + fontFamilyFallback: MeshFonts.sansFallback, + fontSize: 13.5, + fontWeight: FontWeight.w500, + ), + ), + progressIndicatorTheme: ProgressIndicatorThemeData( + color: scheme.primary, + linearTrackColor: scheme.surfaceContainerHigh, + circularTrackColor: Colors.transparent, + ), + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(MeshRadii.sm), + border: Border.all(color: scheme.outline), + ), + textStyle: TextStyle(color: scheme.onSurface, fontSize: 12), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(MeshRadii.pill), + ), + textStyle: const TextStyle( + fontFamily: MeshFonts.sans, + fontFamilyFallback: MeshFonts.sansFallback, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), ); } @@ -443,6 +595,16 @@ class MeshTheme { ); } + /// Color-emoji style with platform fallbacks and stable vertical metrics. + static TextStyle emoji({double fontSize = 28}) { + return TextStyle( + fontFamily: MeshFonts.emoji, + fontFamilyFallback: MeshFonts.emojiFallback, + fontSize: fontSize, + height: 1, + ); + } + /// Color-code an SNR value for consistency across the app. static Color snrColor(num? snr, {required bool blocked}) { if (blocked) return MeshPalette.alert; diff --git a/lib/widgets/battery_indicator.dart b/lib/widgets/battery_indicator.dart index ccea59dd..9a74d7d5 100644 --- a/lib/widgets/battery_indicator.dart +++ b/lib/widgets/battery_indicator.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../connector/meshcore_connector.dart'; +import '../theme/mesh_theme.dart'; class BatteryUi { final IconData icon; @@ -10,19 +11,19 @@ class BatteryUi { BatteryUi batteryUiForPercent(int? percent) { if (percent == null) { - return const BatteryUi(Icons.battery_unknown, Colors.grey); + return const BatteryUi(Icons.battery_unknown, null); } final p = percent.clamp(0, 100); return switch (p) { - <= 5 => const BatteryUi(Icons.battery_alert, Colors.redAccent), - <= 15 => const BatteryUi(Icons.battery_0_bar, Colors.redAccent), - <= 30 => const BatteryUi(Icons.battery_1_bar, Colors.orange), - <= 45 => const BatteryUi(Icons.battery_2_bar, Colors.amber), - <= 60 => const BatteryUi(Icons.battery_3_bar, Colors.lightGreen), - <= 80 => const BatteryUi(Icons.battery_5_bar, Colors.green), - _ => const BatteryUi(Icons.battery_full, Colors.green), + <= 5 => const BatteryUi(Icons.battery_alert, MeshPalette.alert), + <= 15 => const BatteryUi(Icons.battery_0_bar, MeshPalette.alert), + <= 30 => const BatteryUi(Icons.battery_1_bar, MeshPalette.warn), + <= 45 => const BatteryUi(Icons.battery_2_bar, MeshPalette.warn), + <= 60 => const BatteryUi(Icons.battery_3_bar, null), + <= 80 => const BatteryUi(Icons.battery_5_bar, null), + _ => const BatteryUi(Icons.battery_full, MeshPalette.signal), }; } @@ -76,9 +77,9 @@ class _BatteryIndicatorState extends State { Flexible( child: Text( displayText, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, + style: MeshTheme.mono( + fontSize: 11, + fontWeight: FontWeight.w600, color: batteryUi.color, ), maxLines: 1, diff --git a/lib/widgets/device_tile.dart b/lib/widgets/device_tile.dart index 9012128c..ef0f5441 100644 --- a/lib/widgets/device_tile.dart +++ b/lib/widgets/device_tile.dart @@ -1,9 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; + import '../l10n/l10n.dart'; +import '../theme/mesh_theme.dart'; +import 'mesh_ui.dart'; import 'signal_ui.dart'; -/// A reusable tile widget for displaying a MeshCore device in a list +/// A MeshCard-based row for displaying a scanned BLE device. +/// Shows an AvatarCircle (router icon, deterministic hue from device name), +/// device name, mono MAC address, mono RSSI dBm, and SignalBars on the right. +/// While connecting, shows a small progress ring instead of signal bars. class DeviceTile extends StatelessWidget { final ScanResult scanResult; final VoidCallback? onTap; @@ -23,27 +30,10 @@ class DeviceTile extends StatelessWidget { final name = device.platformName.isNotEmpty ? device.platformName : scanResult.advertisementData.advName; + final displayName = name.isNotEmpty ? name : context.l10n.common_unknownDevice; + final mac = device.remoteId.toString(); + final scheme = Theme.of(context).colorScheme; - return ListTile( - enabled: onTap != null || isConnecting, - leading: _buildSignalIcon(rssi), - title: Text( - name.isNotEmpty ? name : context.l10n.common_unknownDevice, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - subtitle: Text(device.remoteId.toString()), - trailing: isConnecting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : null, - onTap: onTap, - ); - } - - Widget _buildSignalIcon(int rssi) { final tier = rssi >= -60 ? 0 : rssi >= -70 @@ -55,15 +45,77 @@ class DeviceTile extends StatelessWidget { : 4; final signalUi = signalUiForStrengthTier(tier); - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(signalUi.icon, color: signalUi.color), - Text( - '$rssi dBm', - style: TextStyle(fontSize: 10, color: signalUi.color), - ), - ], + return MeshCard( + onTap: onTap == null + ? null + : () { + HapticFeedback.selectionClick(); + onTap!(); + }, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + AvatarCircle( + name: displayName, + size: 42, + icon: Icons.router, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayName, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + Text( + mac, + style: MeshTheme.mono( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + if (isConnecting) + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: scheme.primary, + ), + ) + else + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Icon(signalUi.icon, size: 16, color: signalUi.color), + const SizedBox(height: 3), + Text( + '$rssi dBm', + style: MeshTheme.mono( + fontSize: 10, + color: signalUi.color, + ), + ), + ], + ), + ], + ), ); } } diff --git a/lib/widgets/elements_ui.dart b/lib/widgets/elements_ui.dart index 0c462499..55d930db 100644 --- a/lib/widgets/elements_ui.dart +++ b/lib/widgets/elements_ui.dart @@ -29,31 +29,68 @@ class FeatureToggleRow extends StatefulWidget { class _FeatureToggleRow extends State { @override Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: SwitchListTile( - title: Text(widget.title), - subtitle: Text(widget.subtitle), - value: widget.value, - onChanged: widget.onChanged, - contentPadding: EdgeInsets.zero, + final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + widget.subtitle, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), ), - ), - if (widget.hasRefreshing) - IconButton( - icon: widget.isRefreshing - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh, size: 20), - onPressed: widget.isRefreshing ? null : widget.onRefresh, - tooltip: widget.refreshTooltip, - visualDensity: VisualDensity.compact, + const SizedBox(width: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: widget.value, + onChanged: widget.onChanged, + ), + if (widget.hasRefreshing) ...[ + const SizedBox(width: 4), + widget.isRefreshing + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 1.8, + color: scheme.primary, + ), + ) + : IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: widget.onRefresh, + tooltip: widget.refreshTooltip, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ), + ], + ], ), - ], + ], + ), ); } } diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart index 87fd1c9c..3a8fdf79 100644 --- a/lib/widgets/emoji_picker.dart +++ b/lib/widgets/emoji_picker.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../l10n/app_localizations.dart'; import '../l10n/l10n.dart'; +import '../theme/mesh_theme.dart'; class EmojiPicker extends StatelessWidget { final Function(String) onEmojiSelected; @@ -257,7 +258,11 @@ class EmojiPicker extends StatelessWidget { ), child: Text( emoji, - style: const TextStyle(fontSize: 28), + style: MeshTheme.emoji(), + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ), ), ), ), @@ -298,7 +303,12 @@ class EmojiPicker extends StatelessWidget { child: Center( child: Text( emojis[index], - style: const TextStyle(fontSize: 28), + style: MeshTheme.emoji(), + textHeightBehavior: + const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ), ), ), ), diff --git a/lib/widgets/empty_state.dart b/lib/widgets/empty_state.dart index 718c1c44..2f0c9847 100644 --- a/lib/widgets/empty_state.dart +++ b/lib/widgets/empty_state.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; /// A centered empty state display with icon, title, and optional subtitle/action. -class EmptyState extends StatelessWidget { +/// Features a tinted icon circle, fade+slide entrance animation, and clear +/// typography hierarchy using the MeshCore design system. +class EmptyState extends StatefulWidget { final IconData icon; final String title; final String? subtitle; @@ -15,29 +17,97 @@ class EmptyState extends StatelessWidget { this.action, }); + @override + State createState() => _EmptyStateState(); +} + +class _EmptyStateState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + ); + late final CurvedAnimation _curve = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + ); + + @override + void initState() { + super.initState(); + _controller.forward(); + } + + @override + void dispose() { + _curve.dispose(); + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 64, color: onSurfaceVariant.withValues(alpha: 0.6)), - const SizedBox(height: 16), - Text(title, style: TextStyle(fontSize: 16, color: onSurfaceVariant)), - if (subtitle != null) ...[ - const SizedBox(height: 8), - Text( - subtitle!, - style: TextStyle( - fontSize: 14, - color: onSurfaceVariant.withValues(alpha: 0.8), - ), - textAlign: TextAlign.center, + final scheme = Theme.of(context).colorScheme; + return FadeTransition( + opacity: _curve, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.06), + end: Offset.zero, + ).animate(_curve), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scheme.primary.withValues(alpha: 0.08), + border: Border.all( + color: scheme.primary.withValues(alpha: 0.18), + width: 1.5, + ), + ), + child: Icon( + widget.icon, + size: 36, + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: scheme.onSurface, + letterSpacing: -0.1, + ), + textAlign: TextAlign.center, + ), + if (widget.subtitle != null) ...[ + const SizedBox(height: 8), + Text( + widget.subtitle!, + style: TextStyle( + fontSize: 13.5, + color: scheme.onSurfaceVariant, + height: 1.45, + ), + textAlign: TextAlign.center, + ), + ], + if (widget.action != null) ...[ + const SizedBox(height: 28), + widget.action!, + ], + ], ), - ], - if (action != null) ...[const SizedBox(height: 24), action!], - ], + ), + ), ), ); } diff --git a/lib/widgets/jump_to_bottom_button.dart b/lib/widgets/jump_to_bottom_button.dart index 3f6d96e2..6eab28a9 100644 --- a/lib/widgets/jump_to_bottom_button.dart +++ b/lib/widgets/jump_to_bottom_button.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; + import '../helpers/chat_scroll_controller.dart'; +import '../theme/mesh_theme.dart'; class JumpToBottomButton extends StatelessWidget { final ChatScrollController scrollController; @@ -8,6 +10,7 @@ class JumpToBottomButton extends StatelessWidget { @override Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; return ValueListenableBuilder( valueListenable: scrollController.showJumpToBottom, builder: (context, show, _) { @@ -15,9 +18,36 @@ class JumpToBottomButton extends StatelessWidget { return Positioned( right: 16, bottom: 16, - child: FloatingActionButton.small( - onPressed: scrollController.jumpToBottom, - child: const Icon(Icons.keyboard_arrow_down), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: scrollController.jumpToBottom, + borderRadius: BorderRadius.circular(MeshRadii.pill), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scheme.surfaceContainerHigh.withValues(alpha: 0.92), + border: Border.all( + color: scheme.outlineVariant, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + Icons.keyboard_arrow_down, + size: 22, + color: scheme.primary, + ), + ), + ), ), ); }, diff --git a/lib/widgets/mesh_ui.dart b/lib/widgets/mesh_ui.dart new file mode 100644 index 00000000..bfbe679f --- /dev/null +++ b/lib/widgets/mesh_ui.dart @@ -0,0 +1,643 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../theme/mesh_theme.dart'; + +/// MeshCore shared design kit. +/// +/// Building blocks used across all screens so the app reads as one product: +/// [SectionHeader], [MeshCard], [StatusChip], [StatTile], [AvatarCircle], +/// [SignalBars], [RouteChip], [PulseDot], [BottomSheetHeader] + +/// [showMeshSheet], [ErrorRetryCard], and [ListEntrance]. + +/// Small-caps mono section label, optionally with a trailing widget. +class SectionHeader extends StatelessWidget { + final String label; + final Widget? trailing; + final EdgeInsetsGeometry padding; + + const SectionHeader( + this.label, { + super.key, + this.trailing, + this.padding = const EdgeInsets.fromLTRB(16, 20, 16, 8), + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: padding, + child: Row( + children: [ + Expanded( + child: Text( + label.toUpperCase(), + style: MeshTheme.accentLabel(color: scheme.onSurfaceVariant), + overflow: TextOverflow.ellipsis, + ), + ), + ?trailing, + ], + ), + ); + } +} + +/// Bordered surface card with press feedback. The standard container for +/// grouped content and tappable list entries. +class MeshCard extends StatelessWidget { + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry margin; + final Color? color; + final Color? borderColor; + final double radius; + + const MeshCard({ + super.key, + required this.child, + this.onTap, + this.onLongPress, + this.padding = const EdgeInsets.all(14), + this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + this.color, + this.borderColor, + this.radius = MeshRadii.md, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final shape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + side: BorderSide(color: borderColor ?? scheme.outlineVariant), + ); + return Padding( + padding: margin, + child: Material( + color: color ?? scheme.surfaceContainerLow, + shape: shape, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress == null + ? null + : () { + HapticFeedback.selectionClick(); + onLongPress!(); + }, + child: Padding(padding: padding, child: child), + ), + ), + ); + } +} + +/// Tinted pill chip for statuses: a dot or icon plus a short label. +class StatusChip extends StatelessWidget { + final String label; + final Color color; + final IconData? icon; + final bool pulse; + final double fontSize; + + const StatusChip({ + super.key, + required this.label, + required this.color, + this.icon, + this.pulse = false, + this.fontSize = 11.5, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(MeshRadii.pill), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) + Icon(icon, size: fontSize + 2, color: color) + else + PulseDot(color: color, size: 7, animate: pulse), + const SizedBox(width: 5), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MeshTheme.mono( + fontSize: fontSize, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ], + ), + ); + } +} + +/// Compact metric tile: icon, mono value (+ optional unit), small label. +class StatTile extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final String? unit; + final Color? color; + final VoidCallback? onTap; + + const StatTile({ + super.key, + required this.icon, + required this.label, + required this.value, + this.unit, + this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final accent = color ?? scheme.primary; + return MeshCard( + onTap: onTap, + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(icon, size: 14, color: accent), + const SizedBox(width: 6), + Expanded( + child: Text( + label.toUpperCase(), + style: MeshTheme.accentLabel( + color: scheme.onSurfaceVariant, + fontSize: 9, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 6), + Text.rich( + TextSpan( + text: value, + style: MeshTheme.mono( + fontSize: 17, + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + children: [ + if (unit != null) + TextSpan( + text: ' $unit', + style: MeshTheme.mono( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} + +/// Initials avatar with a deterministic per-name hue, or a fixed [color] +/// for node-type coloring. Optional [icon] replaces initials. +class AvatarCircle extends StatelessWidget { + final String name; + final double size; + final Color? color; + final IconData? icon; + + const AvatarCircle({ + super.key, + required this.name, + this.size = 40, + this.color, + this.icon, + }); + + static const _hues = [ + MeshPalette.blue, + MeshPalette.magenta, + MeshPalette.signal, + MeshPalette.warn, + Color(0xFF8FA8F0), + Color(0xFF6FD9CE), + ]; + + Color _colorFor(String s) { + var h = 0; + for (final c in s.codeUnits) { + h = (h * 31 + c) & 0x7fffffff; + } + return _hues[h % _hues.length]; + } + + @override + Widget build(BuildContext context) { + final accent = color ?? _colorFor(name); + final initials = _initials(name); + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: accent.withValues(alpha: 0.14), + border: Border.all(color: accent.withValues(alpha: 0.4)), + ), + alignment: Alignment.center, + child: icon != null + ? Icon(icon, size: size * 0.5, color: accent) + : Text( + initials, + style: MeshTheme.mono( + fontSize: size * 0.36, + fontWeight: FontWeight.w700, + color: accent, + ), + ), + ); + } + + static String _initials(String name) { + final words = name + .trim() + .split(RegExp(r'\s+')) + .where((w) => w.isNotEmpty) + .toList(); + if (words.isEmpty) return '?'; + if (words.length == 1) { + return words.first.characters.take(2).toString().toUpperCase(); + } + return (words.first.characters.take(1).toString() + + words[1].characters.take(1).toString()) + .toUpperCase(); + } +} + +/// Four-bar signal strength indicator driven by an SNR value (dB), colored +/// with the shared [MeshTheme.snrColor] ramp. +class SignalBars extends StatelessWidget { + final double? snr; + final double height; + + const SignalBars({super.key, required this.snr, this.height = 14}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final color = MeshTheme.snrColor(snr, blocked: false); + final active = snr == null + ? 0 + : snr! > 0 + ? 4 + : snr! > -5 + ? 3 + : snr! > -12 + ? 2 + : 1; + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate(4, (i) { + final on = i < active; + return Container( + width: 3, + height: height * (0.4 + i * 0.2), + margin: const EdgeInsets.only(right: 2), + decoration: BoxDecoration( + color: on ? color : scheme.outlineVariant, + borderRadius: BorderRadius.circular(1), + ), + ); + }), + ); + } +} + +/// Chip describing how a message was routed: direct (with hop count) vs flood. +class RouteChip extends StatelessWidget { + final bool isDirect; + final int? hops; + + const RouteChip({super.key, required this.isDirect, this.hops}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final label = isDirect + ? (hops == null || hops == 0 ? 'DIRECT' : '$hops HOP${hops == 1 ? '' : 'S'}') + : 'FLOOD'; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(MeshRadii.xs), + border: Border.all(color: scheme.outlineVariant), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isDirect ? Icons.trending_flat : Icons.podcasts, + size: 11, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 3), + Text( + label, + style: MeshTheme.accentLabel( + color: scheme.onSurfaceVariant, + fontSize: 8.5, + ), + ), + ], + ), + ); + } +} + +/// Small status dot, optionally with a soft breathing animation. +class PulseDot extends StatefulWidget { + final Color color; + final double size; + final bool animate; + + const PulseDot({ + super.key, + required this.color, + this.size = 8, + this.animate = false, + }); + + @override + State createState() => _PulseDotState(); +} + +class _PulseDotState extends State + with SingleTickerProviderStateMixin { + // Created eagerly: a lazy `late final` initializer would run on first + // access — which can be dispose(), where ticker creation throws. + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + ); + if (widget.animate) _controller.repeat(reverse: true); + } + + @override + void didUpdateWidget(PulseDot old) { + super.didUpdateWidget(old); + if (widget.animate && !_controller.isAnimating) { + _controller.repeat(reverse: true); + } else if (!widget.animate && _controller.isAnimating) { + _controller.stop(); + _controller.value = 0; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: widget.animate + ? Tween(begin: 0.35, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ) + : const AlwaysStoppedAnimation(1.0), + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.color, + boxShadow: [ + BoxShadow( + color: widget.color.withValues(alpha: 0.45), + blurRadius: widget.size * 0.7, + ), + ], + ), + ), + ); + } +} + +/// Standard modal sheet header: drag handle, title, optional subtitle and +/// trailing action, and a close button. +class BottomSheetHeader extends StatelessWidget { + final String title; + final String? subtitle; + final Widget? trailing; + + const BottomSheetHeader({ + super.key, + required this.title, + this.subtitle, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 8, 4), + child: Column( + children: [ + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: scheme.outline, + borderRadius: BorderRadius.circular(2), + ), + ), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ?trailing, + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(context).maybePop(), + ), + ], + ), + ], + ), + ); + } +} + +/// Shows a modal bottom sheet with the app-standard shape, scroll behavior +/// and safe-area handling. Pair the content with [BottomSheetHeader]. +Future showMeshSheet( + BuildContext context, { + required WidgetBuilder builder, + bool isScrollControlled = true, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: isScrollControlled, + useSafeArea: true, + showDragHandle: false, + builder: (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: builder(context), + ), + ); +} + +/// Inline error surface with an optional retry action. +class ErrorRetryCard extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + final String? retryLabel; + + const ErrorRetryCard({ + super.key, + required this.message, + this.onRetry, + this.retryLabel, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return MeshCard( + color: scheme.error.withValues(alpha: 0.08), + borderColor: scheme.error.withValues(alpha: 0.35), + child: Row( + children: [ + Icon(Icons.error_outline, color: scheme.error, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: TextStyle(color: scheme.error, fontSize: 13), + ), + ), + if (onRetry != null) + TextButton( + onPressed: onRetry, + child: Text(retryLabel ?? 'Retry'), + ), + ], + ), + ); + } +} + +/// Staggered fade + slide entrance for list items. Wrap each item and pass +/// its [index]; animation only plays once per widget lifecycle. +class ListEntrance extends StatefulWidget { + final int index; + final Widget child; + + const ListEntrance({super.key, required this.index, required this.child}); + + @override + State createState() => _ListEntranceState(); +} + +class _ListEntranceState extends State + with SingleTickerProviderStateMixin { + // Created eagerly: a lazy `late final` initializer would run on first + // access — which can be dispose(), where ticker creation throws. + late final AnimationController _controller; + late final CurvedAnimation _curve; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + ); + _curve = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + ); + final delay = Duration(milliseconds: 24 * widget.index.clamp(0, 12)); + Future.delayed(delay, () { + if (mounted) _controller.forward(); + }); + } + + @override + void dispose() { + _curve.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _curve, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.04), + end: Offset.zero, + ).animate(_curve), + child: widget.child, + ), + ); + } +} diff --git a/lib/widgets/message_status_icon.dart b/lib/widgets/message_status_icon.dart index cf9cd7d6..1d2bbd45 100644 --- a/lib/widgets/message_status_icon.dart +++ b/lib/widgets/message_status_icon.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../l10n/l10n.dart'; +import '../theme/mesh_theme.dart'; class MessageStatusIcon extends StatefulWidget { final bool isAcked; @@ -71,7 +72,11 @@ class _MessageStatusIconState extends State if (widget.isFailed) { return Semantics( label: l10n.messageStatus_failed, - child: Icon(Icons.cancel, size: size, color: colorScheme.error), + child: Icon( + Icons.cancel, + size: size, + color: colorScheme.error, + ), ); } @@ -92,7 +97,10 @@ class _MessageStatusIconState extends State : widget.isAcked ? l10n.messageStatus_delivered : l10n.messageStatus_sent; - final Color color = delivered ? colorScheme.tertiary : baseColor; + // Use palette colors: tertiary (warn/amber) for acked/repeated, base for sent. + final Color color = delivered + ? MeshPalette.signal.withValues(alpha: 0.9) + : baseColor; return Semantics( label: label, diff --git a/lib/widgets/path_map_ui.dart b/lib/widgets/path_map_ui.dart new file mode 100644 index 00000000..cfd3672e --- /dev/null +++ b/lib/widgets/path_map_ui.dart @@ -0,0 +1,659 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import '../l10n/l10n.dart'; +import '../models/display_path.dart'; +import '../models/path_playback.dart'; +import '../theme/mesh_theme.dart'; + +/// Shared UI for the path map screens (live path trace and received-message +/// path map): packet-flow animation overlays, single/combined view toggle, +/// playback controls, and the multi-path summary/legend. + +enum PathViewMode { single, combined } + +const Color kPrimaryPathColor = Colors.blueAccent; +const List kAlternatePathColors = [ + Color(0xFF8B5CF6), // purple + MeshPalette.signal, // green + MeshPalette.warn, // amber + MeshPalette.magenta, +]; + +double getPathDistanceMeters(List points) { + if (points.length <= 1) return 0.0; + + double distanceMeters = 0.0; + final distanceCalculator = Distance(); + + for (int i = 0; i < points.length - 1; i++) { + distanceMeters += distanceCalculator(points[i], points[i + 1]); + } + + return distanceMeters; +} + +String formatDistance(double distanceMeters, {required bool isImperial}) { + if (isImperial) { + return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} mi)'; + } + return '(${(distanceMeters / 1000).toStringAsFixed(2)} km)'; +} + +String formatLastObserved(BuildContext context, DateTime timestamp) { + final l10n = context.l10n; + final diff = DateTime.now().difference(timestamp); + if (diff.isNegative || diff.inMinutes < 5) return l10n.contacts_lastSeenNow; + if (diff.inMinutes < 60) return l10n.contacts_lastSeenMinsAgo(diff.inMinutes); + if (diff.inHours < 24) { + return diff.inHours == 1 + ? l10n.contacts_lastSeenHourAgo + : l10n.contacts_lastSeenHoursAgo(diff.inHours); + } + return diff.inDays == 1 + ? l10n.contacts_lastSeenDayAgo + : l10n.contacts_lastSeenDaysAgo(diff.inDays); +} + +/// Polylines for the visible paths: shared-segment halos (combined view), +/// dashed runs for estimated segments, dimming for unfocused paths and for +/// the selected path while its packet animation is running. +List buildMultiPathPolylines({ + required List visible, + required DisplayPath? selected, + required bool combined, + required bool animating, +}) { + final lines = []; + + if (combined && visible.length > 1) { + final counts = {}; + for (final path in visible) { + for (var i = 0; i < path.points.length - 1; i++) { + counts.update( + _segmentKey(path.points[i], path.points[i + 1]), + (v) => v + 1, + ifAbsent: () => 1, + ); + } + } + final drawn = {}; + for (final path in visible) { + for (var i = 0; i < path.points.length - 1; i++) { + final key = _segmentKey(path.points[i], path.points[i + 1]); + if ((counts[key] ?? 0) < 2 || !drawn.add(key)) continue; + lines.add( + Polyline( + points: [path.points[i], path.points[i + 1]], + strokeWidth: 11, + color: Colors.white.withValues(alpha: 0.22), + ), + ); + } + } + } + + void addPath(DisplayPath path, {required bool isSelected}) { + final dimmedByFocus = combined && !isSelected; + final alpha = dimmedByFocus ? 0.38 : (isSelected && animating ? 0.30 : 1.0); + final width = isSelected ? 5.0 : 3.0; + var i = 0; + while (i < path.segmentEstimated.length) { + final dashed = path.segmentEstimated[i]; + var j = i; + while (j < path.segmentEstimated.length && + path.segmentEstimated[j] == dashed) { + j++; + } + lines.add( + Polyline( + points: path.points.sublist(i, j + 1), + strokeWidth: width, + color: path.color.withValues(alpha: alpha), + pattern: dashed + ? StrokePattern.dashed(segments: const [10, 7]) + : const StrokePattern.solid(), + ), + ); + i = j; + } + } + + for (final path in visible) { + if (path.id != selected?.id) addPath(path, isSelected: false); + } + if (selected != null && visible.any((p) => p.id == selected.id)) { + addPath(selected, isSelected: true); + } + + return lines; +} + +String _segmentKey(LatLng a, LatLng b) { + final ka = + '${a.latitude.toStringAsFixed(6)},${a.longitude.toStringAsFixed(6)}'; + final kb = + '${b.latitude.toStringAsFixed(6)},${b.longitude.toStringAsFixed(6)}'; + return ka.compareTo(kb) <= 0 ? '$ka|$kb' : '$kb|$ka'; +} + +/// Bright traversed portion plus the glow on the active segment. +List buildPacketTrailPolylines( + PathPlaybackController playback, + Color color, +) { + if (!playback.started || !playback.hasPath) return const []; + final seg = playback.currentSegment; + final traversed = [ + ...playback.points.take(seg + 1), + playback.position, + ]; + return [ + Polyline( + points: [playback.points[seg], playback.position], + strokeWidth: 8, + color: Colors.white.withValues(alpha: 0.45), + ), + Polyline(points: traversed, strokeWidth: 5, color: color), + ]; +} + +/// The moving packet dot and the pulse ring at the hop it just reached. +List buildPacketMarkers( + PathPlaybackController playback, + Color color, +) { + if (!playback.started || !playback.hasPath) return const []; + final markers = []; + + final dwell = playback.dwellProgress; + if (dwell != null) { + final reached = playback.points[playback.reachedPointIndex]; + markers.add( + Marker( + point: reached, + width: 56, + height: 56, + child: IgnorePointer( + child: Center( + child: Container( + width: 24 + 28 * dwell, + height: 24 + 28 * dwell, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: color.withValues(alpha: 1.0 - dwell), + width: 3, + ), + ), + ), + ), + ), + ), + ); + } + + markers.add( + Marker( + point: playback.position, + width: 24, + height: 24, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.7), + blurRadius: 12, + spreadRadius: 2, + ), + ], + ), + ), + ), + ), + ); + + return markers; +} + +/// Bottom sheet listing the paths that pass through a shared node. +void showSharedNodeSheet( + BuildContext context, { + required String title, + required List paths, + required ValueChanged onSelect, +}) { + final l10n = context.l10n; + showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + title, + style: MeshTheme.mono( + fontSize: 14, + fontWeight: FontWeight.w700, + color: MeshPalette.ink, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + l10n.pathMap_sharedNodeCount(paths.length), + style: TextStyle(fontSize: 12, color: MeshPalette.ink3), + ), + ), + const SizedBox(height: 8), + for (final path in paths) + ListTile( + dense: true, + leading: _colorDot(path.color), + title: Text( + path.label, + style: MeshTheme.mono(fontSize: 13, color: MeshPalette.ink), + ), + trailing: Text( + l10n.pathMap_hopCount(path.totalTransmissions), + style: MeshTheme.mono(fontSize: 11, color: MeshPalette.ink3), + ), + onTap: () { + Navigator.pop(sheetContext); + onSelect(path); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); +} + +Widget _colorDot(Color color) => Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), +); + +/// Floating Single/Combined toggle for the top of a path map Stack. +class PathViewModeToggle extends StatelessWidget { + final PathViewMode mode; + final ValueChanged onChanged; + + const PathViewModeToggle({ + super.key, + required this.mode, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Positioned( + top: 12, + left: 0, + right: 0, + child: Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: MeshPalette.bg1.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(MeshRadii.pill), + ), + child: SegmentedButton( + style: const ButtonStyle( + visualDensity: VisualDensity(horizontal: -3, vertical: -3), + ), + showSelectedIcon: false, + segments: [ + ButtonSegment( + value: PathViewMode.single, + label: Text(l10n.pathMap_viewSingle), + ), + ButtonSegment( + value: PathViewMode.combined, + label: Text(l10n.pathMap_viewCombined), + ), + ], + selected: {mode}, + onSelectionChanged: (selection) => onChanged(selection.first), + ), + ), + ), + ); + } +} + +/// Compact playback control row: animation toggle, step/play/replay buttons, +/// follow-packet lock, speed chip, and the live "Hop x of y · from → to" +/// label. +class PathAnimationControls extends StatelessWidget { + final PathPlaybackController playback; + final DisplayPath? selected; + final bool animationEnabled; + final VoidCallback onToggleAnimation; + final bool followEnabled; + final VoidCallback onToggleFollow; + + const PathAnimationControls({ + super.key, + required this.playback, + required this.selected, + required this.animationEnabled, + required this.onToggleAnimation, + required this.followEnabled, + required this.onToggleFollow, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: playback, + builder: (context, _) { + final l10n = context.l10n; + final enabled = animationEnabled && playback.hasPath; + final path = selected; + String? hopLabel; + if (animationEnabled && + playback.started && + playback.hasPath && + path != null) { + final seg = playback.currentSegment; + final row = seg < path.rowForSegment.length + ? path.rowForSegment[seg] + : 0; + final from = path.pointLabels[seg]; + final to = path.pointLabels[seg + 1]; + hopLabel = + '${l10n.pathMap_hopOf(row + 1, path.totalTransmissions)} · $from → $to'; + } + + Widget controlButton({ + required IconData icon, + required String tooltip, + VoidCallback? onPressed, + Color? color, + }) => IconButton( + icon: Icon(icon, size: 20, color: color), + tooltip: tooltip, + onPressed: onPressed, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 12, 2), + child: Row( + children: [ + controlButton( + icon: Icons.animation, + tooltip: animationEnabled + ? l10n.pathMap_animationOff + : l10n.pathMap_animationOn, + color: animationEnabled ? MeshPalette.blue : MeshPalette.ink4, + onPressed: onToggleAnimation, + ), + controlButton( + icon: Icons.skip_previous, + tooltip: l10n.pathMap_stepBack, + onPressed: enabled && playback.started + ? playback.stepBack + : null, + ), + controlButton( + icon: playback.playing ? Icons.pause : Icons.play_arrow, + tooltip: playback.playing ? l10n.pathMap_pause : l10n.pathMap_play, + onPressed: enabled ? playback.togglePlay : null, + ), + controlButton( + icon: Icons.skip_next, + tooltip: l10n.pathMap_stepForward, + onPressed: enabled ? playback.stepForward : null, + ), + controlButton( + icon: Icons.replay, + tooltip: l10n.pathMap_replay, + onPressed: enabled ? playback.replay : null, + ), + controlButton( + icon: followEnabled ? Icons.lock : Icons.lock_open, + tooltip: followEnabled + ? l10n.pathMap_unfollowPacket + : l10n.pathMap_followPacket, + color: followEnabled ? MeshPalette.blue : null, + onPressed: enabled ? onToggleFollow : null, + ), + TextButton( + onPressed: enabled ? playback.cycleSpeed : null, + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 6), + minimumSize: const Size(36, 30), + ), + child: Text( + playback.speed == 0.5 ? '0.5×' : '${playback.speed.toInt()}×', + style: MeshTheme.mono(fontSize: 12), + ), + ), + Expanded( + child: Text( + hopLabel ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: MeshTheme.mono( + fontSize: 10.5, + color: MeshPalette.ink2, + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +/// Marker/line style legend swatches. +class PathMiniLegend extends StatelessWidget { + final bool combined; + final bool showInferred; + + const PathMiniLegend({ + super.key, + required this.combined, + this.showInferred = true, + }); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + Widget item(Widget swatch, String text) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + swatch, + const SizedBox(width: 4), + Text(text, style: TextStyle(fontSize: 11, color: MeshPalette.ink3)), + ], + ); + Widget dashSample() => Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 3; i++) + Container( + width: 5, + height: 3, + margin: const EdgeInsets.only(right: 2), + color: MeshPalette.ink3, + ), + ], + ); + return Wrap( + spacing: 12, + runSpacing: 2, + children: [ + item(_colorDot(MeshPalette.signal), l10n.pathTrace_legendGpsConfirmed), + if (showInferred) + item(_colorDot(MeshPalette.warn), l10n.pathTrace_legendInferred), + if (combined) ...[ + item( + Container( + width: 14, + height: 6, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(3), + ), + ), + l10n.pathMap_legendShared, + ), + item(dashSample(), l10n.pathMap_legendEstimated), + ], + ], + ); + } +} + +/// "Observed paths: N" header plus one selectable row per path with hop +/// count, distance, GPS-confirmed count, last-observed time, and an eye +/// toggle for visibility. +class PathSummaryList extends StatelessWidget { + final List paths; + final String selectedId; + final Set hiddenIds; + final bool isImperial; + final ValueChanged onSelect; + final ValueChanged onToggleVisibility; + final VoidCallback onShowAll; + + const PathSummaryList({ + super.key, + required this.paths, + required this.selectedId, + required this.hiddenIds, + required this.isImperial, + required this.onSelect, + required this.onToggleVisibility, + required this.onShowAll, + }); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 2, 12, 0), + child: Row( + children: [ + Text( + l10n.pathMap_observedPaths(paths.length), + style: MeshTheme.accentLabel(color: MeshPalette.ink3), + ), + const Spacer(), + if (hiddenIds.isNotEmpty) + TextButton( + onPressed: onShowAll, + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: const Size(0, 26), + ), + child: Text( + l10n.pathMap_showAllPaths, + style: const TextStyle(fontSize: 11), + ), + ), + ], + ), + ), + for (final path in paths) _buildRow(context, path), + const SizedBox(height: 4), + ], + ); + } + + Widget _buildRow(BuildContext context, DisplayPath path) { + final l10n = context.l10n; + final isSelected = path.id == selectedId; + final hidden = hiddenIds.contains(path.id); + final timestamp = path.record?.timestamp; + final parts = [ + '${l10n.pathMap_hopCount(path.totalTransmissions)} ${formatDistance(path.distanceMeters, isImperial: isImperial)}', + l10n.pathMap_gpsCount(path.gpsConfirmedHops, path.hopBytes.length), + if (timestamp != null) formatLastObserved(context, timestamp), + ]; + + return InkWell( + onTap: () => onSelect(path), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isSelected ? MeshPalette.bg3 : Colors.transparent, + borderRadius: BorderRadius.circular(MeshRadii.sm), + ), + child: Row( + children: [ + Opacity( + opacity: hidden ? 0.45 : 1, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _colorDot(path.color), + const SizedBox(width: 8), + Text( + path.label, + style: MeshTheme.mono( + fontSize: 12, + fontWeight: isSelected + ? FontWeight.w700 + : FontWeight.w500, + color: MeshPalette.ink, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + child: Opacity( + opacity: hidden ? 0.45 : 1, + child: Text( + parts.join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MeshTheme.mono(fontSize: 10.5, color: MeshPalette.ink3), + ), + ), + ), + IconButton( + icon: Icon( + hidden ? Icons.visibility_off : Icons.visibility, + size: 16, + color: hidden ? MeshPalette.ink4 : MeshPalette.ink3, + ), + tooltip: hidden ? l10n.pathMap_showPath : l10n.pathMap_hidePath, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 30, minHeight: 30), + onPressed: () => onToggleVisibility(path), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/quick_switch_bar.dart b/lib/widgets/quick_switch_bar.dart index bcb4781f..90b90b40 100644 --- a/lib/widgets/quick_switch_bar.dart +++ b/lib/widgets/quick_switch_bar.dart @@ -2,12 +2,14 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import '../l10n/l10n.dart'; +import '../theme/mesh_theme.dart'; class QuickSwitchBar extends StatelessWidget { final int selectedIndex; final ValueChanged onDestinationSelected; final int contactsUnreadCount; final int channelsUnreadCount; + final bool highContrast; const QuickSwitchBar({ super.key, @@ -15,6 +17,7 @@ class QuickSwitchBar extends StatelessWidget { required this.onDestinationSelected, this.contactsUnreadCount = 0, this.channelsUnreadCount = 0, + this.highContrast = false, }); @override @@ -22,6 +25,14 @@ class QuickSwitchBar extends StatelessWidget { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final labelStyle = theme.textTheme.labelMedium ?? const TextStyle(); + final background = highContrast ? MapPalette.panelDark : Colors.transparent; + final selectedColor = highContrast + ? MapPalette.textPrimary + : colorScheme.onPrimary; + final unselectedColor = highContrast + ? MapPalette.textSecondary + : colorScheme.onSurfaceVariant; + final indicator = highContrast ? MapPalette.selected : colorScheme.primary; return SizedBox( width: double.infinity, @@ -31,9 +42,11 @@ class QuickSwitchBar extends StatelessWidget { filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), child: DecoratedBox( decoration: BoxDecoration( - color: Colors.transparent, + color: background, border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.4), + color: highContrast + ? MapPalette.border + : colorScheme.outlineVariant.withValues(alpha: 0.4), ), ), child: NavigationBarTheme( @@ -41,22 +54,18 @@ class QuickSwitchBar extends StatelessWidget { backgroundColor: Colors.transparent, surfaceTintColor: Colors.transparent, shadowColor: Colors.transparent, - indicatorColor: colorScheme.primaryContainer, + indicatorColor: indicator, labelTextStyle: WidgetStateProperty.resolveWith((states) { final isSelected = states.contains(WidgetState.selected); return labelStyle.copyWith( fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + color: isSelected ? selectedColor : unselectedColor, ); }), iconTheme: WidgetStateProperty.resolveWith((states) { final isSelected = states.contains(WidgetState.selected); return IconThemeData( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + color: isSelected ? selectedColor : unselectedColor, ); }), ), diff --git a/lib/widgets/radio_stats_entry.dart b/lib/widgets/radio_stats_entry.dart index d5fbc670..aab7c4de 100644 --- a/lib/widgets/radio_stats_entry.dart +++ b/lib/widgets/radio_stats_entry.dart @@ -7,6 +7,9 @@ import 'package:meshcore_open/l10n/l10n.dart'; import 'package:meshcore_open/screens/companion_radio_stats_screen.dart'; import 'package:provider/provider.dart'; +import '../theme/mesh_theme.dart'; +import 'mesh_ui.dart'; + void pushCompanionRadioStatsScreen(BuildContext context) { Navigator.push( context, @@ -140,12 +143,12 @@ class AirActivityDotState extends State { @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; final on = widget.active && _blink; - return Icon( - Icons.circle, - size: 12, - color: on ? scheme.primary : scheme.outline, + final scheme = Theme.of(context).colorScheme; + return PulseDot( + color: on ? MeshPalette.blue : scheme.outline, + size: 11, + animate: false, ); } } diff --git a/lib/widgets/repeater_login_dialog.dart b/lib/widgets/repeater_login_dialog.dart index 5b45037d..207bbe5d 100644 --- a/lib/widgets/repeater_login_dialog.dart +++ b/lib/widgets/repeater_login_dialog.dart @@ -9,6 +9,8 @@ import '../l10n/contact_localization.dart'; import '../services/storage_service.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../theme/mesh_theme.dart'; +import '../widgets/mesh_ui.dart'; import '../utils/app_logger.dart'; import 'routing_sheet.dart'; @@ -269,26 +271,40 @@ class _RepeaterLoginDialogState extends State { @override Widget build(BuildContext context) { final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; final connector = context.watch(); final repeater = _resolveRepeater(connector); final isFloodMode = repeater.pathOverride == -1; return AlertDialog( title: Row( children: [ - Icon(Icons.cell_tower, color: Theme.of(context).colorScheme.tertiary), - const SizedBox(width: 8), + AvatarCircle( + name: repeater.name, + size: 40, + color: MeshPalette.warn, + icon: Icons.cell_tower, + ), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l10n.login_repeaterLogin), + Text( + l10n.login_repeaterLogin, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), Text( repeater.name, style: TextStyle( - fontSize: 14, + fontSize: 13, fontWeight: FontWeight.normal, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: scheme.onSurfaceVariant, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], ), @@ -319,14 +335,14 @@ class _RepeaterLoginDialogState extends State { Icon( Icons.error, size: 18, - color: Theme.of(context).colorScheme.error, + color: scheme.error, ), const SizedBox(width: 8), Expanded( child: Text( _loginError!, style: TextStyle( - color: Theme.of(context).colorScheme.error, + color: scheme.error, fontSize: 13, ), ), @@ -341,7 +357,6 @@ class _RepeaterLoginDialogState extends State { decoration: InputDecoration( labelText: l10n.login_password, hintText: l10n.login_enterPassword, - border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.lock), suffixIcon: IconButton( icon: Icon( @@ -390,9 +405,9 @@ class _RepeaterLoginDialogState extends State { children: [ Text( l10n.login_routing, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + style: MeshTheme.accentLabel( + color: scheme.onSurfaceVariant, + fontSize: 11, ), ), const Spacer(), @@ -421,7 +436,7 @@ class _RepeaterLoginDialogState extends State { Icons.auto_mode, size: 20, color: !isFloodMode - ? Theme.of(context).primaryColor + ? scheme.primary : null, ), const SizedBox(width: 8), @@ -444,7 +459,7 @@ class _RepeaterLoginDialogState extends State { Icons.waves, size: 20, color: isFloodMode - ? Theme.of(context).primaryColor + ? scheme.primary : null, ), const SizedBox(width: 8), @@ -468,7 +483,7 @@ class _RepeaterLoginDialogState extends State { repeater.pathLabel(context.l10n), style: TextStyle( fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: scheme.onSurfaceVariant, ), ), const SizedBox(height: 8), @@ -502,7 +517,7 @@ class _RepeaterLoginDialogState extends State { height: 16, child: CircularProgressIndicator( strokeWidth: 2, - color: Theme.of(context).colorScheme.onPrimary, + color: scheme.onPrimary, ), ), const SizedBox(width: 12), diff --git a/lib/widgets/room_login_dialog.dart b/lib/widgets/room_login_dialog.dart index a6475cfb..34a3f994 100644 --- a/lib/widgets/room_login_dialog.dart +++ b/lib/widgets/room_login_dialog.dart @@ -10,6 +10,8 @@ import '../l10n/contact_localization.dart'; import '../services/storage_service.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../theme/mesh_theme.dart'; +import '../widgets/mesh_ui.dart'; import '../utils/app_logger.dart'; import '../helpers/snack_bar_builder.dart'; import 'routing_sheet.dart'; @@ -226,26 +228,40 @@ class _RoomLoginDialogState extends State { @override Widget build(BuildContext context) { final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; final connector = context.watch(); final repeater = _resolveRepeater(connector); final isFloodMode = repeater.pathOverride == -1; return AlertDialog( title: Row( children: [ - Icon(Icons.group, color: Theme.of(context).colorScheme.secondary), - const SizedBox(width: 8), + AvatarCircle( + name: repeater.name, + size: 40, + color: MeshPalette.magenta, + icon: Icons.group, + ), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(l10n.login_roomLogin), + Text( + l10n.login_roomLogin, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), Text( repeater.name, style: TextStyle( - fontSize: 14, + fontSize: 13, fontWeight: FontWeight.normal, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: scheme.onSurfaceVariant, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], ), @@ -275,7 +291,6 @@ class _RoomLoginDialogState extends State { decoration: InputDecoration( labelText: l10n.login_password, hintText: l10n.login_enterPassword, - border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.lock), suffixIcon: IconButton( icon: Icon( @@ -319,9 +334,9 @@ class _RoomLoginDialogState extends State { children: [ Text( l10n.login_routing, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + style: MeshTheme.accentLabel( + color: scheme.onSurfaceVariant, + fontSize: 11, ), ), const Spacer(), @@ -350,7 +365,7 @@ class _RoomLoginDialogState extends State { Icons.auto_mode, size: 20, color: !isFloodMode - ? Theme.of(context).primaryColor + ? scheme.primary : null, ), const SizedBox(width: 8), @@ -373,7 +388,7 @@ class _RoomLoginDialogState extends State { Icons.waves, size: 20, color: isFloodMode - ? Theme.of(context).primaryColor + ? scheme.primary : null, ), const SizedBox(width: 8), @@ -397,7 +412,7 @@ class _RoomLoginDialogState extends State { repeater.pathLabel(context.l10n), style: TextStyle( fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: scheme.onSurfaceVariant, ), ), const SizedBox(height: 8), @@ -431,7 +446,7 @@ class _RoomLoginDialogState extends State { height: 16, child: CircularProgressIndicator( strokeWidth: 2, - color: Theme.of(context).colorScheme.onPrimary, + color: scheme.onPrimary, ), ), const SizedBox(width: 12), diff --git a/lib/widgets/signal_ui.dart b/lib/widgets/signal_ui.dart index e0e05111..0edcb9af 100644 --- a/lib/widgets/signal_ui.dart +++ b/lib/widgets/signal_ui.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../theme/mesh_theme.dart'; class SignalUi { final IconData icon; @@ -12,27 +13,27 @@ SignalUi signalUiForStrengthTier(int tier) { case 0: return const SignalUi( icon: Icons.signal_cellular_4_bar, - color: Colors.green, + color: MeshPalette.signal, ); case 1: return const SignalUi( icon: Icons.signal_cellular_alt, - color: Colors.lightGreen, + color: MeshPalette.signalDim, ); case 2: return const SignalUi( icon: Icons.signal_cellular_alt_2_bar, - color: Colors.amber, + color: MeshPalette.warn, ); case 3: return const SignalUi( icon: Icons.signal_cellular_alt_1_bar, - color: Colors.orange, + color: MeshPalette.warnDim, ); default: return const SignalUi( icon: Icons.signal_cellular_alt_1_bar, - color: Colors.red, + color: MeshPalette.alert, ); } } diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index 21efffd1..f57776de 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -5,6 +5,8 @@ import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; +import '../theme/mesh_theme.dart'; +import 'mesh_ui.dart'; import 'signal_ui.dart'; Contact? _getRepeaterPrefixMatchNearLocation( @@ -218,10 +220,6 @@ class _SNRIndicatorState extends State { separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final repeater = directBestRepeaters[index]; - final snrUi = snrUiFromSNR( - repeater.snr, - widget.connector.currentSf, - ); final allContacts = widget.connector.allContacts; final selfLat = widget.connector.selfLatitude; @@ -242,22 +240,47 @@ class _SNRIndicatorState extends State { ); final name = contact?.name; + final hex = repeater.pubkeyFirstByte + .toRadixString(16) + .padLeft(2, '0'); + final snrColor = MeshTheme.snrColor( + repeater.snr, + blocked: false, + ); - return Column( - children: [ - ListTile( - leading: Icon(snrUi.icon, color: snrUi.color), - title: Text( - name ?? - repeater.pubkeyFirstByte - .toRadixString(16) - .padLeft(2, '0'), + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + children: [ + AvatarCircle( + name: name ?? hex, + size: 36, + color: snrColor, ), - subtitle: Text( - 'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}', + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name ?? hex, + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + '${repeater.snr.toStringAsFixed(1)} dB • ${_formatLastUpdated(repeater.lastUpdated)}', + style: MeshTheme.mono( + fontSize: 11, + color: snrColor, + ), + ), + ], + ), ), - ), - ], + ], + ), ); }, ), diff --git a/lib/widgets/telemetry_location_map.dart b/lib/widgets/telemetry_location_map.dart index a73fb640..f8983c04 100644 --- a/lib/widgets/telemetry_location_map.dart +++ b/lib/widgets/telemetry_location_map.dart @@ -11,6 +11,7 @@ import '../models/app_settings.dart'; import '../models/contact.dart'; import '../services/app_settings_service.dart'; import '../services/map_tile_cache_service.dart'; +import 'themed_map_tile_layer.dart'; class TelemetryLocationMap extends StatefulWidget { final double latitude; @@ -114,13 +115,7 @@ class _TelemetryLocationMapState extends State { ), ), children: [ - TileLayer( - urlTemplate: kMapTileUrlTemplate, - tileProvider: tileCache.tileProvider, - userAgentPackageName: - MapTileCacheService.userAgentPackageName, - maxZoom: 19, - ), + ThemedMapTileLayer(tileCache: tileCache), MarkerLayer( markers: [ ...contacts.map(_buildContactMarker), diff --git a/lib/widgets/themed_map_tile_layer.dart b/lib/widgets/themed_map_tile_layer.dart new file mode 100644 index 00000000..c6d2eaad --- /dev/null +++ b/lib/widgets/themed_map_tile_layer.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import '../services/map_tile_cache_service.dart'; + +/// Shared cached map tiles with an automatic dark-mode treatment. +/// +/// The dark style transforms the existing OpenStreetMap raster tiles, so light +/// and dark maps share the same offline cache and network requests. +class ThemedMapTileLayer extends StatelessWidget { + final MapTileCacheService tileCache; + final double opacity; + + const ThemedMapTileLayer({ + super.key, + required this.tileCache, + this.opacity = 1, + }); + + static const ColorFilter _darkMapFilter = ColorFilter.matrix([ + -0.0850, + -0.2861, + -0.0289, + 0, + 120, + -0.0957, + -0.3218, + -0.0325, + 0, + 140, + -0.1169, + -0.3934, + -0.0397, + 0, + 170, + 0, + 0, + 0, + 1, + 0, + ]); + + @override + Widget build(BuildContext context) { + Widget layer = TileLayer( + urlTemplate: kMapTileUrlTemplate, + tileProvider: tileCache.tileProvider, + userAgentPackageName: MapTileCacheService.userAgentPackageName, + maxZoom: 19, + ); + + if (Theme.of(context).brightness == Brightness.dark) { + layer = ColorFiltered(colorFilter: _darkMapFilter, child: layer); + } + if (opacity < 1) { + layer = Opacity(opacity: opacity, child: layer); + } + return layer; + } +} diff --git a/lib/widgets/unread_badge.dart b/lib/widgets/unread_badge.dart index 424cb6f5..1cf5c1d6 100644 --- a/lib/widgets/unread_badge.dart +++ b/lib/widgets/unread_badge.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../theme/mesh_theme.dart'; + class UnreadBadge extends StatelessWidget { final int count; @@ -9,17 +11,18 @@ class UnreadBadge extends StatelessWidget { Widget build(BuildContext context) { final display = count > 9999 ? '9999+' : count.toString(); return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), decoration: BoxDecoration( - color: Colors.redAccent, - borderRadius: BorderRadius.circular(10), + color: MeshPalette.blue.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(MeshRadii.pill), + border: Border.all(color: MeshPalette.blue.withValues(alpha: 0.45)), ), child: Text( display, - style: const TextStyle( - color: Colors.white, + style: MeshTheme.mono( fontSize: 11, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, + color: MeshPalette.blue, ), ), ); diff --git a/lib/widgets/unread_divider.dart b/lib/widgets/unread_divider.dart index f9ebd1d0..d238b1a1 100644 --- a/lib/widgets/unread_divider.dart +++ b/lib/widgets/unread_divider.dart @@ -1,30 +1,49 @@ import 'package:flutter/material.dart'; import '../l10n/l10n.dart'; +import '../theme/mesh_theme.dart'; class UnreadDivider extends StatelessWidget { const UnreadDivider({super.key}); @override Widget build(BuildContext context) { - final color = Theme.of(context).colorScheme.primary; + final scheme = Theme.of(context).colorScheme; + final color = scheme.primary; return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 10), child: Row( children: [ - Expanded(child: Divider(color: color)), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), + Expanded( + child: Container( + height: 1, + color: color.withValues(alpha: 0.25), + ), + ), + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(MeshRadii.pill), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), child: Text( context.l10n.chat_newMessages, - style: TextStyle( + style: MeshTheme.mono( + fontSize: 10.5, + fontWeight: FontWeight.w600, color: color, - fontSize: 12, - fontWeight: FontWeight.w500, ), ), ), - Expanded(child: Divider(color: color)), + const SizedBox(width: 10), + Expanded( + child: Container( + height: 1, + color: color.withValues(alpha: 0.25), + ), + ), ], ), ); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 93e46829..379e36fa 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -8,7 +8,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flserial - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/untranslated.json b/untranslated.json index 9e26dfee..c1e57a13 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1 +1,698 @@ -{} \ No newline at end of file +{ + "bg": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "de": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "es": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "fr": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "hu": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "it": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "ja": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "ko": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "nl": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "pl": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "pt": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "ru": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "sk": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "sl": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "sv": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "uk": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ], + + "zh": [ + "map_searchHint", + "map_activity", + "map_online", + "map_recent", + "map_stale", + "map_visible", + "map_hidden", + "map_centerOnNode", + "map_details", + "map_noGps", + "map_noResults", + "pathMap_viewSingle", + "pathMap_viewCombined", + "pathMap_play", + "pathMap_pause", + "pathMap_replay", + "pathMap_stepBack", + "pathMap_stepForward", + "pathMap_animationOn", + "pathMap_animationOff", + "pathMap_hopOf", + "pathMap_observedPaths", + "pathMap_primary", + "pathMap_alternate", + "pathMap_hopCount", + "pathMap_gpsCount", + "pathMap_legendShared", + "pathMap_legendEstimated", + "pathMap_sharedNodeCount", + "pathMap_partialAnimation", + "pathMap_showAllPaths", + "pathMap_hidePath", + "pathMap_showPath", + "pathMap_collapsePanel", + "pathMap_expandPanel", + "pathMap_noLocation", + "pathMap_followPacket", + "pathMap_unfollowPacket" + ] +} diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 533a1712..f02857f4 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flserial flutter_local_notifications_windows - jni ) set(PLUGIN_BUNDLED_LIBRARIES)