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.
This commit is contained in:
zjs81
2026-06-12 21:04:02 -07:00
parent 6a31d304d3
commit 51d6210920
72 changed files with 16778 additions and 7110 deletions
+13 -1
View File
@@ -25,7 +25,19 @@ void showDismissibleSnackBar(
DismissDirection? dismissDirection, DismissDirection? dismissDirection,
Clip? clipBehavior, 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( messenger.showSnackBar(
SnackBar( SnackBar(
key: key, key: key,
+94 -1
View File
@@ -900,6 +900,17 @@
}, },
"chat_invalidLink": "Invalid link format", "chat_invalidLink": "Invalid link format",
"map_title": "Node Map", "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_lineOfSight": "Line of Sight",
"map_losScreenTitle": "Line of Sight", "map_losScreenTitle": "Line of Sight",
"map_noNodesWithLocation": "No nodes with location data", "map_noNodesWithLocation": "No nodes with location data",
@@ -2501,5 +2512,87 @@
} }
}, },
"pathTrace_legendGpsConfirmed": "GPS confirmed", "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"
} }
+228
View File
@@ -3154,6 +3154,72 @@ abstract class AppLocalizations {
/// **'Node Map'** /// **'Node Map'**
String get map_title; 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. /// No description provided for @map_lineOfSight.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -7701,6 +7767,168 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Inferred position'** /// **'Inferred position'**
String get pathTrace_legendInferred; 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 class _AppLocalizationsDelegate
+140
View File
@@ -1738,6 +1738,39 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get map_title => 'Карта на възлите'; 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 @override
String get map_lineOfSight => 'Линия на видимост'; String get map_lineOfSight => 'Линия на видимост';
@@ -4479,4 +4512,111 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Извлечена позиция'; 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';
} }
+140
View File
@@ -1737,6 +1737,39 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get map_title => 'Karte'; 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 @override
String get map_lineOfSight => 'Sichtlinie'; String get map_lineOfSight => 'Sichtlinie';
@@ -4500,4 +4533,111 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Abgeleitete Position'; 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';
} }
+140
View File
@@ -1703,6 +1703,39 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_title => 'Node Map'; 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 @override
String get map_lineOfSight => 'Line of Sight'; String get map_lineOfSight => 'Line of Sight';
@@ -4405,4 +4438,111 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Inferred position'; 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';
} }
+140
View File
@@ -1734,6 +1734,39 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get map_title => 'Mapa de Nodos'; 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 @override
String get map_lineOfSight => 'Línea de visión'; String get map_lineOfSight => 'Línea de visión';
@@ -4486,4 +4519,111 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Posición inferida'; 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';
} }
+140
View File
@@ -1741,6 +1741,39 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get map_title => 'Carte des nœuds'; 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 @override
String get map_lineOfSight => 'Ligne de vue'; String get map_lineOfSight => 'Ligne de vue';
@@ -4512,4 +4545,111 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Position déduite'; 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';
} }
+140
View File
@@ -1744,6 +1744,39 @@ class AppLocalizationsHu extends AppLocalizations {
@override @override
String get map_title => 'Grafikus ábrázás'; 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 @override
String get map_lineOfSight => 'Látási vonal'; String get map_lineOfSight => 'Látási vonal';
@@ -4500,4 +4533,111 @@ class AppLocalizationsHu extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Feltehető helyzet'; 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';
} }
+140
View File
@@ -1738,6 +1738,39 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_title => 'Mappa Nodi'; 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 @override
String get map_lineOfSight => 'Linea di vista'; String get map_lineOfSight => 'Linea di vista';
@@ -4492,4 +4525,111 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Posizione dedotta'; 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';
} }
+140
View File
@@ -1661,6 +1661,39 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get map_title => 'ノードマップ'; 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 @override
String get map_lineOfSight => '視界'; String get map_lineOfSight => '視界';
@@ -4257,4 +4290,111 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => '推測される位置'; 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';
} }
+140
View File
@@ -1656,6 +1656,39 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get map_title => '노드 매핑'; 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 @override
String get map_lineOfSight => '시야'; String get map_lineOfSight => '시야';
@@ -4254,4 +4287,111 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => '추론된 위치'; 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';
} }
+140
View File
@@ -1721,6 +1721,39 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_title => 'Kaart van de knopen'; 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 @override
String get map_lineOfSight => 'Zichtlijn'; String get map_lineOfSight => 'Zichtlijn';
@@ -4463,4 +4496,111 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Afgeleide positie'; 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';
} }
+140
View File
@@ -1750,6 +1750,39 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_title => 'Mapa węzłów'; 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 @override
String get map_lineOfSight => 'Linia wzroku'; String get map_lineOfSight => 'Linia wzroku';
@@ -4501,4 +4534,111 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Wywnioskowana pozycja'; 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';
} }
+140
View File
@@ -1734,6 +1734,39 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_title => 'Mapa de Nós'; 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 @override
String get map_lineOfSight => 'Linha de visão'; String get map_lineOfSight => 'Linha de visão';
@@ -4480,4 +4513,111 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Posição inferida'; 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';
} }
+140
View File
@@ -1739,6 +1739,39 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_title => 'Карта нод'; 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 @override
String get map_lineOfSight => 'Линия видимости'; String get map_lineOfSight => 'Линия видимости';
@@ -4496,4 +4529,111 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Выведенная позиция'; 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';
} }
+140
View File
@@ -1726,6 +1726,39 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_title => 'Mapa uzlov'; 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 @override
String get map_lineOfSight => 'Úroveň výhľadu'; String get map_lineOfSight => 'Úroveň výhľadu';
@@ -4462,4 +4495,111 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Odvodená poloha'; 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';
} }
+140
View File
@@ -1719,6 +1719,39 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_title => 'Mapa omrežja'; 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 @override
String get map_lineOfSight => 'Linija vida'; String get map_lineOfSight => 'Linija vida';
@@ -4461,4 +4494,111 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Izpeljana lokacija'; 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';
} }
+140
View File
@@ -1712,6 +1712,39 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_title => 'Nodkarta'; 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 @override
String get map_lineOfSight => 'Synlinje'; String get map_lineOfSight => 'Synlinje';
@@ -4435,4 +4468,111 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Antagen position'; 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';
} }
+140
View File
@@ -1732,6 +1732,39 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_title => 'Карта вузлів'; 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 @override
String get map_lineOfSight => 'Пряма видимість'; String get map_lineOfSight => 'Пряма видимість';
@@ -4495,4 +4528,111 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => 'Висновок щодо положення'; 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';
} }
+140
View File
@@ -1634,6 +1634,39 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_title => '节点地图'; 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 @override
String get map_lineOfSight => '视线'; String get map_lineOfSight => '视线';
@@ -4145,4 +4178,111 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get pathTrace_legendInferred => '推测的位置'; 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';
} }
+69
View File
@@ -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<int> 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<LatLng> points;
/// Display name for each entry of [points].
final List<String> pointLabels;
/// Whether each entry of [points] is a GPS-grade position (vs inferred).
final List<bool> pointConfirmed;
/// Per segment (length points-1): true when either endpoint is inferred or
/// unlocatable hops were skipped in between rendered dashed.
final List<bool> segmentEstimated;
/// Per segment: the transmission ordinal of the segment's destination,
/// used to highlight the matching hop-list row during animation.
final List<int> 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,
});
}
+177
View File
@@ -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<double> speedSteps = [0.5, 1.0, 2.0];
late final Ticker _ticker;
List<LatLng> _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<int> activeSegment = ValueNotifier(-1);
PathPlaybackController(TickerProvider vsync) {
_ticker = vsync.createTicker(_onTick);
}
List<LatLng> 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<LatLng> 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();
}
}
+79 -34
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/app_debug_log_service.dart'; import '../services/app_debug_log_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
@@ -58,27 +59,57 @@ class AppDebugLogScreen extends StatelessWidget {
child: hasEntries child: hasEntries
? ListView.separated( ? ListView.separated(
itemCount: entries.length, itemCount: entries.length,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) =>
const Divider(height: 1, color: MeshPalette.line),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = entries[index]; final entry = entries[index];
return ListTile( return Container(
dense: true, color: MeshPalette.bg,
leading: _buildLevelIcon(context, entry.level), padding: const EdgeInsets.symmetric(
title: Text( horizontal: 16,
'[${entry.tag}] ${entry.message}', vertical: 8,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
),
), ),
subtitle: Text( child: Row(
entry.formattedTime, crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle( children: [
fontSize: 10, _buildLevelIcon(context, entry.level),
color: Theme.of( const SizedBox(width: 10),
context, Expanded(
).colorScheme.onSurfaceVariant, 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( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const Icon(
Icons.bug_report_outlined, Icons.bug_report_outlined,
size: 64, size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: MeshPalette.ink3,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
context.l10n.debugLog_noEntries, context.l10n.debugLog_noEntries,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
color: Theme.of( color: MeshPalette.ink3,
context,
).colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
context.l10n.debugLog_enableInSettings, context.l10n.debugLog_enableInSettings,
style: TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: Theme.of( color: MeshPalette.ink3,
context,
).colorScheme.onSurfaceVariant,
), ),
), ),
], ],
@@ -121,19 +148,37 @@ class AppDebugLogScreen extends StatelessWidget {
); );
} }
Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) { Color _levelColor(AppDebugLogLevel level) {
final colorScheme = Theme.of(context).colorScheme;
switch (level) { switch (level) {
case AppDebugLogLevel.info: case AppDebugLogLevel.info:
return Icon(Icons.info_outline, size: 18, color: colorScheme.primary); return MeshPalette.blue;
case AppDebugLogLevel.warning: 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, Icons.warning_amber_outlined,
size: 18, size: 18,
color: colorScheme.tertiary, color: MeshPalette.warn,
); );
case AppDebugLogLevel.error: case AppDebugLogLevel.error:
return Icon(Icons.error_outline, size: 18, color: colorScheme.error); return const Icon(
Icons.error_outline,
size: 18,
color: MeshPalette.alert,
);
} }
} }
} }
File diff suppressed because it is too large Load Diff
+113 -19
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/ble_debug_log_service.dart'; import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
@@ -32,6 +33,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle), title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle),
centerTitle: true,
actions: [ actions: [
IconButton( IconButton(
tooltip: context.l10n.debugLog_copyLog, tooltip: context.l10n.debugLog_copyLog,
@@ -101,23 +103,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
itemCount: showingFrames itemCount: showingFrames
? entries.length ? entries.length
: rawEntries.length, : rawEntries.length,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) =>
const Divider(height: 1, color: MeshPalette.line),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (showingFrames) { if (showingFrames) {
final entry = entries[index]; final entry = entries[index];
final time = final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile( return GestureDetector(
dense: true,
title: Text(entry.description),
subtitle: Text('${entry.hexPreview}\n$time'),
isThreeLine: true,
leading: Icon(
entry.outgoing
? Icons.upload
: Icons.download,
size: 18,
),
onLongPress: () async { onLongPress: () async {
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(
@@ -131,6 +124,60 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
), ),
); );
}, },
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<BleDebugLogScreen> {
final info = _decodeRawPacket(entry.payload); final info = _decodeRawPacket(entry.payload);
final time = final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}'; '${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile( return GestureDetector(
dense: true,
title: Text(info.title),
subtitle: Text('${info.summary}\n$time'),
isThreeLine: true,
leading: const Icon(Icons.download, size: 18),
onTap: () => _showRawDialog(context, info), 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( : Center(
child: Text(context.l10n.debugLog_noBleActivity), child: Text(
context.l10n.debugLog_noBleActivity,
style: const TextStyle(color: MeshPalette.ink3),
),
), ),
), ),
], ],
+297 -224
View File
@@ -25,7 +25,6 @@ import '../models/translation_support.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/chat_text_scale_service.dart'; import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart'; import '../services/translation_service.dart';
import '../helpers/contact_ui.dart';
import '../widgets/byte_count_input.dart'; import '../widgets/byte_count_input.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/chat_zoom_wrapper.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/sync_progress_overlay.dart';
import '../widgets/translated_message_content.dart'; import '../widgets/translated_message_content.dart';
import '../widgets/unread_divider.dart'; import '../widgets/unread_divider.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
@@ -108,7 +109,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
totalMessages: messages.length, totalMessages: messages.length,
onJumped: () { onJumped: () {
if (!mounted) return; if (!mounted) return;
_scrollToMessage(anchor!.messageId); _scrollToMessage(anchor!.messageId, quiet: true);
}, },
); );
}); });
@@ -193,9 +194,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}); });
} }
Future<void> _scrollToMessage(String messageId) async { Future<void> _scrollToMessage(String messageId, {bool quiet = false}) async {
final key = _messageKeys[messageId]; final key = _messageKeys[messageId];
if (key == null) { 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( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.chat_originalMessageNotFound), content: Text(context.l10n.chat_originalMessageNotFound),
@@ -491,6 +495,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final settingsService = context.watch<AppSettingsService>(); final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing; final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing; final isOutgoing = message.isOutgoing;
final scheme = Theme.of(context).colorScheme;
final gifId = GifHelper.parseGif(message.text); final gifId = GifHelper.parseGif(message.text);
final poi = parseMarkerText(message.text); final poi = parseMarkerText(message.text);
final translatedDisplayText = final translatedDisplayText =
@@ -507,9 +512,34 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
? message.pathVariants.first ? message.pathVariants.first
: Uint8List(0)); : 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 maxSwipeOffset = 64.0;
const replySwipeThreshold = 64.0; const replySwipeThreshold = 64.0;
const bodyFontSize = 14.0;
final messageBody = LayoutBuilder( final messageBody = LayoutBuilder(
builder: (context, constraints) => Column( builder: (context, constraints) => Column(
crossAxisAlignment: isOutgoing crossAxisAlignment: isOutgoing
@@ -520,11 +550,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
mainAxisAlignment: isOutgoing mainAxisAlignment: isOutgoing
? MainAxisAlignment.end ? MainAxisAlignment.end
: MainAxisAlignment.start, : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (!isOutgoing) ...[ if (!isOutgoing) ...[
_buildAvatar(message.senderName), _buildAvatar(message.senderName),
const SizedBox(width: 8), const SizedBox(width: 6),
], ],
Flexible( Flexible(
child: GestureDetector( child: GestureDetector(
@@ -540,15 +570,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
vertical: 8, vertical: 8,
), ),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.65, maxWidth: constraints.maxWidth * 0.72,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isOutgoing color: bubbleColor,
? Theme.of(context).colorScheme.primaryContainer borderRadius: borderRadius,
: Theme.of( border: Border.all(color: bubbleBorder, width: 1),
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -564,14 +591,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
: EdgeInsets.zero, : EdgeInsets.zero,
child: Text( child: Text(
message.senderName, message.senderName,
style: TextStyle( style: MeshTheme.mono(
fontSize: 12, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.primary, color: _colorForName(message.senderName),
), ),
), ),
), ),
if (gifId == null) const SizedBox(height: 4), if (gifId == null) const SizedBox(height: 2),
], ],
if (message.replyToMessageId != null) ...[ if (message.replyToMessageId != null) ...[
_buildReplyPreview(message, textScale), _buildReplyPreview(message, textScale),
@@ -594,13 +621,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
url: url:
'https://media.giphy.com/media/$gifId/giphy.gif', 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing fallbackTextColor: textColor.withValues(
? Theme.of(context) alpha: 0.7,
.colorScheme ),
.onPrimaryContainer
.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.6),
), ),
), ),
], ],
@@ -615,40 +638,48 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
displayText: translatedDisplayText, displayText: translatedDisplayText,
originalText: originalDisplayText, originalText: originalDisplayText,
style: TextStyle( style: TextStyle(
color: textColor,
fontSize: bodyFontSize * textScale, fontSize: bodyFontSize * textScale,
), ),
originalStyle: TextStyle( originalStyle: TextStyle(
fontSize: bodyFontSize * textScale, fontSize: bodyFontSize * textScale,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
color: Theme.of(context) color: textColor.withValues(alpha: 0.72),
.colorScheme
.onSurface
.withValues(alpha: 0.72),
), ),
), ),
), ),
], ],
), ),
if (enableTracing && displayPath.isNotEmpty) ...[ if (enableTracing && displayPath.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 3),
Padding( Padding(
padding: gifId != null padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8) ? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero, : EdgeInsets.zero,
child: Text( child: Row(
context.l10n.channels_via( mainAxisSize: MainAxisSize.min,
_formatPathPrefixes(displayPath), children: [
), RouteChip(
style: TextStyle( isDirect: (message.pathLength ?? -1) >= 0,
fontSize: 11 * textScale, hops: (message.pathLength ?? -1) >= 0
color: Theme.of( ? message.pathLength
context, : null,
).colorScheme.onSurfaceVariant, ),
), 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(
padding: gifId != null padding: gifId != null
? const EdgeInsets.only( ? const EdgeInsets.only(
@@ -662,30 +693,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
Text( Text(
_formatTime(context, message.timestamp), _formatTime(context, message.timestamp),
style: TextStyle( style: MeshTheme.mono(
fontSize: 11 * textScale, fontSize: 10 * textScale,
color: Theme.of( color: metaColor,
context,
).colorScheme.onSurfaceVariant,
), ),
), ),
if (enableTracing && message.repeatCount > 0) ...[ if (enableTracing && message.repeatCount > 0) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Icon( Icon(
Icons.repeat, Icons.repeat,
size: 12 * textScale, size: 11 * textScale,
color: Theme.of( color: metaColor,
context,
).colorScheme.onSurfaceVariant,
), ),
const SizedBox(width: 2), const SizedBox(width: 2),
Text( Text(
'${message.repeatCount}', '${message.repeatCount}',
style: TextStyle( style: MeshTheme.mono(
fontSize: 11 * textScale, fontSize: 10 * textScale,
color: Theme.of( color: metaColor,
context,
).colorScheme.onSurfaceVariant,
), ),
), ),
], ],
@@ -705,6 +730,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
isFailed: isFailed:
message.status == message.status ==
ChannelMessageStatus.failed, ChannelMessageStatus.failed,
onColor: metaColor,
), ),
], ],
], ],
@@ -720,7 +746,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (message.reactions.isNotEmpty) ...[ if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Padding( Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
child: _buildReactionsDisplay(message), child: _buildReactionsDisplay(message),
), ),
], ],
@@ -739,7 +765,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} else { } else {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 3),
child: messageBody, child: messageBody,
); );
} }
@@ -836,7 +862,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(MeshRadii.sm),
border: Border( border: Border(
left: BorderSide(color: colorScheme.primary, width: 3), left: BorderSide(color: colorScheme.primary, width: 3),
), ),
@@ -849,9 +875,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
style: TextStyle( style: TextStyle(
fontSize: 11 * textScale, fontSize: 11 * textScale,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isOwnNode color: isOwnNode ? colorScheme.primary : colorScheme.onSurface,
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
@@ -863,6 +887,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
} }
Widget _buildReactionsDisplay(ChannelMessage message) { Widget _buildReactionsDisplay(ChannelMessage message) {
final scheme = Theme.of(context).colorScheme;
return Wrap( return Wrap(
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
@@ -873,27 +898,29 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(MeshRadii.pill),
border: Border.all( border: Border.all(color: scheme.outlineVariant, width: 1),
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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) ...[ if (count > 1) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'$count', '$count',
style: TextStyle( style: MeshTheme.mono(
fontSize: 12, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSecondaryContainer, color: scheme.onSurface,
), ),
), ),
], ],
@@ -912,20 +939,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
String senderName, { String senderName, {
Widget? trailing, Widget? trailing,
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final textColor = isOutgoing final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.7); final metaColor = textColor.withValues(alpha: 0.7);
final channelColor = widget.channel.isPublicChannel
? Colors.orange
: Colors.blue;
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
icon: Icon(Icons.location_on_outlined, color: channelColor), icon: Icon(Icons.location_on_outlined, color: scheme.primary),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40), constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
onPressed: () { onPressed: () {
@@ -991,41 +1013,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
} }
Widget _buildAvatar(String senderName) { Widget _buildAvatar(String senderName) {
final initial = firstCharacterOrEmoji(senderName); return AvatarCircle(name: senderName, size: 32);
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 _buildReplyBanner(double textScale) { Widget _buildReplyBanner(double textScale) {
final message = _replyingToMessage!; final message = _replyingToMessage!;
final scheme = Theme.of(context).colorScheme;
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: scheme.surfaceContainerHigh,
border: Border( border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1), bottom: BorderSide(color: scheme.outlineVariant, width: 1),
), ),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(Icons.reply, size: 18, color: scheme.primary),
Icons.reply,
size: 18,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Column( child: Column(
@@ -1033,10 +1038,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
Text( Text(
context.l10n.chat_replyingTo(message.senderName), context.l10n.chat_replyingTo(message.senderName),
style: TextStyle( style: MeshTheme.mono(
fontSize: 12 * textScale, fontSize: 11 * textScale,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSecondaryContainer, color: scheme.primary,
), ),
), ),
Text( Text(
@@ -1045,9 +1050,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 11 * textScale, fontSize: 11 * textScale,
color: Theme.of( color: scheme.onSurfaceVariant,
context,
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
), ),
), ),
], ],
@@ -1056,7 +1059,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
IconButton( IconButton(
icon: const Icon(Icons.close, size: 18), icon: const Icon(Icons.close, size: 18),
onPressed: _cancelReply, onPressed: _cancelReply,
color: Theme.of(context).colorScheme.onSecondaryContainer, color: scheme.onSurfaceVariant,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44), constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
), ),
], ],
@@ -1068,6 +1071,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName); final maxBytes = maxChannelMessageBytes(connector.selfName);
final settings = context.watch<AppSettingsService>().settings; final settings = context.watch<AppSettingsService>().settings;
final scheme = Theme.of(context).colorScheme;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -1081,123 +1085,166 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}, },
), ),
Container( Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: scheme.surface,
boxShadow: [ border: Border(
BoxShadow( top: BorderSide(color: scheme.outlineVariant, width: 1),
color: Colors.black.withValues(alpha: 0.1), ),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
), ),
child: Row( child: SafeArea(
children: [ child: Padding(
IconButton( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
icon: const Icon(Icons.gif_box), child: Row(
onPressed: () => _showGifPicker(context), crossAxisAlignment: CrossAxisAlignment.center,
tooltip: context.l10n.chat_sendGif, children: [
), IconButton(
if (settings.translationEnabled) icon: const Icon(Icons.gif_box),
MessageTranslationButton( onPressed: () => _showGifPicker(context),
enabled: settings.composerTranslationEnabled, tooltip: context.l10n.chat_sendGif,
languageCode: settings.translationTargetLanguageCode, ),
onPressed: _showTranslationOptions, if (settings.translationEnabled)
), MessageTranslationButton(
Expanded( enabled: settings.composerTranslationEnabled,
child: ValueListenableBuilder<TextEditingValue>( languageCode: settings.translationTargetLanguageCode,
valueListenable: _textController, onPressed: _showTranslationOptions,
builder: (context, value, child) { ),
final gifId = GifHelper.parseGif(value.text); Expanded(
if (gifId != null) { child: ValueListenableBuilder<TextEditingValue>(
return Focus( valueListenable: _textController,
autofocus: true, builder: (context, value, child) {
onKeyEvent: (node, event) { final gifId = GifHelper.parseGif(value.text);
if (event is KeyDownEvent && if (gifId != null) {
(event.logicalKey == LogicalKeyboardKey.enter || return Focus(
event.logicalKey == autofocus: true,
LogicalKeyboardKey.numpadEnter)) { onKeyEvent: (node, event) {
_sendMessage(); if (event is KeyDownEvent &&
return KeyEventResult.handled; (event.logicalKey ==
} LogicalKeyboardKey.enter ||
return KeyEventResult.ignored; event.logicalKey ==
}, LogicalKeyboardKey.numpadEnter)) {
child: Row( _sendMessage();
children: [ return KeyEventResult.handled;
Expanded( }
child: ClipRRect( return KeyEventResult.ignored;
borderRadius: BorderRadius.circular(12), },
child: GifMessage( child: Row(
url: children: [
'https://media.giphy.com/media/$gifId/giphy.gif', Expanded(
backgroundColor: Theme.of( child: ClipRRect(
context, borderRadius: BorderRadius.circular(12),
).colorScheme.surfaceContainerHighest, child: GifMessage(
fallbackTextColor: Theme.of(context) url:
.colorScheme 'https://media.giphy.com/media/$gifId/giphy.gif',
.onSurface backgroundColor:
.withValues(alpha: 0.6), scheme.surfaceContainerHighest,
maxSize: 160, 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), enabledBorder: OutlineInputBorder(
IconButton( borderRadius: BorderRadius.circular(
icon: const Icon(Icons.close), MeshRadii.pill,
onPressed: () { ),
_textController.clear(); borderSide: BorderSide(
_textFieldFocusNode.requestFocus(); 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<TextEditingValue>(
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<ChannelChatScreen> {
) && ) &&
(message.translatedText?.trim().isEmpty ?? true); (message.translatedText?.trim().isEmpty ?? true);
showModalBottomSheet( showMeshSheet(
context: context, context,
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
BottomSheetHeader(
title: message.text.length > 40
? '${message.text.substring(0, 40)}'
: message.text,
subtitle: message.senderName.isNotEmpty
? message.senderName
: null,
),
ListTile( ListTile(
leading: const Icon(Icons.reply), leading: const Icon(Icons.reply),
title: Text(context.l10n.chat_reply), title: Text(context.l10n.chat_reply),
@@ -1402,7 +1457,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_markAsUnread(message); _markAsUnread(message);
}, },
), ),
const Divider(), const Divider(height: 1),
ListTile( ListTile(
leading: Icon( leading: Icon(
Icons.delete_outline, Icons.delete_outline,
@@ -1417,6 +1472,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
await _deleteMessage(message); await _deleteMessage(message);
}, },
), ),
const SizedBox(height: 8),
], ],
), ),
), ),
@@ -1499,6 +1555,23 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(','); .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 { class _SwipeReplyBubble extends StatefulWidget {
@@ -1640,7 +1713,7 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
onPointerUp: (event) => _handleSwipePointerUp(event.position), onPointerUp: (event) => _handleSwipePointerUp(event.position),
onPointerCancel: (_) => _resetSwipe(), onPointerCancel: (_) => _resetSwipe(),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 8),
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+243 -170
View File
@@ -30,7 +30,6 @@ import '../widgets/chat_zoom_wrapper.dart';
import '../widgets/byte_count_input.dart'; import '../widgets/byte_count_input.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
import '../helpers/contact_ui.dart';
import '../widgets/emoji_picker.dart'; import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart'; import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart'; import '../widgets/jump_to_bottom_button.dart';
@@ -43,6 +42,8 @@ import '../widgets/translated_message_content.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import '../widgets/unread_divider.dart'; import '../widgets/unread_divider.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'telemetry_screen.dart'; import 'telemetry_screen.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
@@ -448,119 +449,157 @@ class _ChatScreenState extends State<ChatScreen> {
Widget _buildInputBar(MeshCoreConnector connector) { Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes(); final maxBytes = maxContactMessageBytes();
final colorScheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final settings = context.watch<AppSettingsService>().settings; final settings = context.watch<AppSettingsService>().settings;
return Container( return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface, color: scheme.surface,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), border: Border(top: BorderSide(color: scheme.outlineVariant, width: 1)),
), ),
child: SafeArea( child: SafeArea(
child: Row( child: Padding(
children: [ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
IconButton( child: Row(
icon: const Icon(Icons.gif_box), crossAxisAlignment: CrossAxisAlignment.center,
onPressed: () => _showGifPicker(context), children: [
tooltip: context.l10n.chat_sendGif, IconButton(
), icon: const Icon(Icons.gif_box),
if (settings.translationEnabled) onPressed: () => _showGifPicker(context),
MessageTranslationButton( tooltip: context.l10n.chat_sendGif,
enabled: settings.composerTranslationEnabled,
languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions,
), ),
Expanded( if (settings.translationEnabled)
child: ValueListenableBuilder<TextEditingValue>( MessageTranslationButton(
valueListenable: _textController, enabled: settings.composerTranslationEnabled,
builder: (context, value, child) { languageCode: settings.translationTargetLanguageCode,
final gifId = GifHelper.parseGif(value.text); onPressed: _showTranslationOptions,
if (gifId != null) { ),
return Focus( Expanded(
autofocus: true, child: ValueListenableBuilder<TextEditingValue>(
onKeyEvent: (node, event) { valueListenable: _textController,
if (event is KeyDownEvent && builder: (context, value, child) {
(event.logicalKey == LogicalKeyboardKey.enter || final gifId = GifHelper.parseGif(value.text);
event.logicalKey == if (gifId != null) {
LogicalKeyboardKey.numpadEnter)) { return Focus(
_sendMessage(connector); autofocus: true,
return KeyEventResult.handled; onKeyEvent: (node, event) {
} if (event is KeyDownEvent &&
return KeyEventResult.ignored; (event.logicalKey == LogicalKeyboardKey.enter ||
}, event.logicalKey ==
child: Row( LogicalKeyboardKey.numpadEnter)) {
children: [ _sendMessage(connector);
Expanded( return KeyEventResult.handled;
child: ClipRRect( }
borderRadius: BorderRadius.circular(12), return KeyEventResult.ignored;
child: GifMessage( },
url: child: Row(
'https://media.giphy.com/media/$gifId/giphy.gif', children: [
backgroundColor: Expanded(
colorScheme.surfaceContainerHighest, child: ClipRRect(
fallbackTextColor: colorScheme.onSurface borderRadius: BorderRadius.circular(12),
.withValues(alpha: 0.6), child: GifMessage(
maxSize: 160, 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( filled: true,
icon: const Icon(Icons.close), fillColor: scheme.surfaceContainerLow,
onPressed: () { contentPadding: const EdgeInsets.symmetric(
_textController.clear(); horizontal: 18,
_textFieldFocusNode.requestFocus(); vertical: 12,
}, ),
),
],
), ),
); );
} },
return ByteCountedTextField( ),
maxBytes: maxBytes, ),
controller: _textController, const SizedBox(width: 6),
focusNode: _textFieldFocusNode, ValueListenableBuilder<TextEditingValue>(
hintText: context.l10n.chat_typeMessage, valueListenable: _textController,
onSubmitted: (_) => _sendMessage(connector), builder: (context, value, _) {
encoder: final hasText = value.text.trim().isNotEmpty;
(connector.isContactSmazEnabled( return AnimatedContainer(
widget.contact.publicKeyHex, duration: const Duration(milliseconds: 180),
) || curve: Curves.easeInOut,
connector.isContactCyr2LatEnabled( child: IconButton.filled(
widget.contact.publicKeyHex, icon: const Icon(Icons.send, size: 20),
)) tooltip: context.l10n.chat_sendMessageTo(
? (text) => connector.prepareContactOutboundText( _resolveContact(connector).name,
widget.contact,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
), ),
filled: true, style: IconButton.styleFrom(
fillColor: Theme.of( backgroundColor: hasText
context, ? scheme.primary
).colorScheme.surfaceContainerLow, : scheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric( foregroundColor: hasText
horizontal: 20, ? scheme.onPrimary
vertical: 14, : 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<ChatScreen> {
) && ) &&
(message.translatedText?.trim().isEmpty ?? true); (message.translatedText?.trim().isEmpty ?? true);
showModalBottomSheet( showMeshSheet(
context: context, context,
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
BottomSheetHeader(
title: message.text.length > 40
? '${message.text.substring(0, 40)}'
: message.text,
),
// Can't react to your own messages // Can't react to your own messages
if (!message.isOutgoing) if (!message.isOutgoing)
ListTile( ListTile(
@@ -1118,7 +1162,7 @@ class _ChatScreenState extends State<ChatScreen> {
_openChat(context, contact); _openChat(context, contact);
}, },
), ),
const Divider(), const Divider(height: 1),
ListTile( ListTile(
leading: Icon( leading: Icon(
Icons.delete_outline, Icons.delete_outline,
@@ -1133,6 +1177,7 @@ class _ChatScreenState extends State<ChatScreen> {
await _deleteMessage(message); await _deleteMessage(message);
}, },
), ),
const SizedBox(height: 8),
], ],
), ),
), ),
@@ -1221,20 +1266,45 @@ class _MessageBubble extends StatelessWidget {
final settingsService = context.watch<AppSettingsService>(); final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing; final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing; final isOutgoing = message.isOutgoing;
final colorScheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final gifId = GifHelper.parseGif(message.text); final gifId = GifHelper.parseGif(message.text);
final poi = parseMarkerText(message.text); final poi = parseMarkerText(message.text);
final isFailed = message.status == MessageStatus.failed; final isFailed = message.status == MessageStatus.failed;
// Bubble colors outgoing uses MeshPalette.me / meBorder / meInk.
final bubbleColor = isFailed final bubbleColor = isFailed
? colorScheme.errorContainer ? scheme.errorContainer
: (isOutgoing : isOutgoing
? colorScheme.primary ? MeshPalette.me
: colorScheme.surfaceContainerHighest); : scheme.surfaceContainerLow;
final bubbleBorder = isFailed
? scheme.error
: isOutgoing
? MeshPalette.meBorder
: scheme.outlineVariant;
final textColor = isFailed final textColor = isFailed
? colorScheme.onErrorContainer ? scheme.onErrorContainer
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); : isOutgoing
final metaColor = textColor.withValues(alpha: 0.7); ? MeshPalette.meInk
: scheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.65);
const bodyFontSize = 14.0; 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 // Do not strip room-server author bytes here: the parser stores them in
// fourByteRoomContactKey, so message.text is safe to render as-is. // fourByteRoomContactKey, so message.text is safe to render as-is.
final messageText = message.text; final messageText = message.text;
@@ -1246,8 +1316,9 @@ class _MessageBubble extends StatelessWidget {
final originalDisplayText = isOutgoing final originalDisplayText = isOutgoing
? message.originalText ? message.originalText
: (translatedDisplayText != messageText ? messageText : null); : (translatedDisplayText != messageText ? messageText : null);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 3),
child: Column( child: Column(
crossAxisAlignment: isOutgoing crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end ? CrossAxisAlignment.end
@@ -1263,11 +1334,11 @@ class _MessageBubble extends StatelessWidget {
mainAxisAlignment: isOutgoing mainAxisAlignment: isOutgoing
? MainAxisAlignment.end ? MainAxisAlignment.end
: MainAxisAlignment.start, : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
if (!isOutgoing) ...[ if (!isOutgoing) ...[
_buildAvatar(senderName, colorScheme), _buildAvatar(senderName),
const SizedBox(width: 8), const SizedBox(width: 6),
], ],
Flexible( Flexible(
child: Container( child: Container(
@@ -1278,14 +1349,12 @@ class _MessageBubble extends StatelessWidget {
vertical: 8, vertical: 8,
), ),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.65, maxWidth: constraints.maxWidth * 0.72,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bubbleColor, color: bubbleColor,
borderRadius: BorderRadius.circular(16), borderRadius: borderRadius,
border: isFailed border: Border.all(color: bubbleBorder, width: 1),
? Border.all(color: colorScheme.error)
: null,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1301,14 +1370,14 @@ class _MessageBubble extends StatelessWidget {
: EdgeInsets.zero, : EdgeInsets.zero,
child: Text( child: Text(
senderName, senderName,
style: TextStyle( style: MeshTheme.mono(
fontSize: 12, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
color: colorScheme.primary, color: _colorForName(senderName),
), ),
), ),
), ),
if (gifId == null) const SizedBox(height: 4), if (gifId == null) const SizedBox(height: 2),
], ],
if (poi != null) if (poi != null)
_buildPoiMessage( _buildPoiMessage(
@@ -1349,7 +1418,7 @@ class _MessageBubble extends StatelessWidget {
fontSize: bodyFontSize * textScale, fontSize: bodyFontSize * textScale,
), ),
originalStyle: TextStyle( originalStyle: TextStyle(
color: textColor.withValues(alpha: 0.78), color: textColor.withValues(alpha: 0.72),
fontSize: bodyFontSize * textScale, fontSize: bodyFontSize * textScale,
), ),
), ),
@@ -1359,7 +1428,7 @@ class _MessageBubble extends StatelessWidget {
if (enableTracing && if (enableTracing &&
isOutgoing && isOutgoing &&
message.retryCount > 0) ...[ message.retryCount > 0) ...[
const SizedBox(height: 4), const SizedBox(height: 3),
Padding( Padding(
padding: gifId != null padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8) ? const EdgeInsets.symmetric(horizontal: 8)
@@ -1372,15 +1441,15 @@ class _MessageBubble extends StatelessWidget {
.settings .settings
.maxMessageRetries, .maxMessageRetries,
), ),
style: TextStyle( style: MeshTheme.mono(
fontSize: 10 * textScale, fontSize: 9.5 * textScale,
color: metaColor, color: metaColor,
fontWeight: FontWeight.w500,
), ),
), ),
), ),
], ],
const SizedBox(height: 4), const SizedBox(height: 3),
// Meta row: timestamp + status icon + optional tracing
Padding( Padding(
padding: gifId != null padding: gifId != null
? const EdgeInsets.only( ? const EdgeInsets.only(
@@ -1395,13 +1464,13 @@ class _MessageBubble extends StatelessWidget {
children: [ children: [
Text( Text(
_formatTime(message.timestamp), _formatTime(message.timestamp),
style: TextStyle( style: MeshTheme.mono(
fontSize: 10 * textScale, fontSize: 10 * textScale,
color: metaColor, color: metaColor,
), ),
), ),
if (isOutgoing) ...[ if (isOutgoing) ...[
const SizedBox(width: 4), const SizedBox(width: 2),
MessageStatusIcon( MessageStatusIcon(
size: 12 * textScale, size: 12 * textScale,
onColor: metaColor, onColor: metaColor,
@@ -1418,25 +1487,21 @@ class _MessageBubble extends StatelessWidget {
message.tripTimeMs != null && message.tripTimeMs != null &&
message.status == message.status ==
MessageStatus.delivered) ...[ MessageStatus.delivered) ...[
const SizedBox(width: 4), const SizedBox(width: 2),
Icon( Icon(
Icons.speed, Icons.speed,
size: 10 * textScale, size: 10 * textScale,
color: isOutgoing color: isOutgoing
? metaColor ? metaColor
: Theme.of( : scheme.tertiary,
context,
).colorScheme.tertiary,
), ),
Text( Text(
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s', '${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
style: TextStyle( style: MeshTheme.mono(
fontSize: 9 * textScale, fontSize: 9 * textScale,
color: isOutgoing color: isOutgoing
? metaColor ? metaColor
: Theme.of( : scheme.tertiary,
context,
).colorScheme.tertiary,
), ),
), ),
], ],
@@ -1454,8 +1519,8 @@ class _MessageBubble extends StatelessWidget {
if (message.reactions.isNotEmpty) ...[ if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Padding( Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
child: _buildReactionsDisplay(context, message, colorScheme), child: _buildReactionsDisplay(context, message, scheme),
), ),
], ],
], ],
@@ -1532,7 +1597,7 @@ class _MessageBubble extends StatelessWidget {
Widget _buildReactionsDisplay( Widget _buildReactionsDisplay(
BuildContext context, BuildContext context,
Message message, Message message,
ColorScheme colorScheme, ColorScheme scheme,
) { ) {
return Wrap( return Wrap(
spacing: 6, spacing: 6,
@@ -1555,28 +1620,33 @@ class _MessageBubble extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isFailed color: isFailed
? colorScheme.errorContainer ? scheme.errorContainer
: colorScheme.secondaryContainer, : scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(MeshRadii.pill),
border: Border.all( border: Border.all(
color: isFailed color: isFailed ? scheme.error : scheme.outlineVariant,
? colorScheme.error
: colorScheme.outline.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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) ...[ if (count > 1) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'$count', '$count',
style: TextStyle( style: MeshTheme.mono(
fontSize: 12, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w700,
color: colorScheme.onSecondaryContainer, color: scheme.onSurface,
), ),
), ),
], ],
@@ -1587,13 +1657,13 @@ class _MessageBubble extends StatelessWidget {
height: 8, height: 8,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 1.5, strokeWidth: 1.5,
color: colorScheme.onSecondaryContainer, color: scheme.primary,
), ),
), ),
], ],
if (isFailed) ...[ if (isFailed) ...[
const SizedBox(width: 2), 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) { Widget _buildAvatar(String senderName) {
final initial = firstCharacterOrEmoji(senderName); return AvatarCircle(name: senderName, size: 32);
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,
),
),
);
} }
String _formatTime(DateTime time) { String _formatTime(DateTime time) {
@@ -1628,3 +1684,20 @@ class _MessageBubble extends StatelessWidget {
return '$hour:$minute'; 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];
}
+87 -68
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
class ChromeRequiredScreen extends StatelessWidget { class ChromeRequiredScreen extends StatelessWidget {
const ChromeRequiredScreen({super.key}); const ChromeRequiredScreen({super.key});
@@ -7,78 +9,95 @@ class ChromeRequiredScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final theme = Theme.of(context); final scheme = Theme.of(context).colorScheme;
final colorScheme = theme.colorScheme;
return Scaffold( return Scaffold(
body: Container( body: SafeArea(
width: double.infinity, child: Center(
padding: const EdgeInsets.symmetric(horizontal: 32), child: SingleChildScrollView(
color: colorScheme.surface, padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( // Icon in tinted circle
padding: const EdgeInsets.all(24), Container(
decoration: BoxDecoration( width: 88,
color: colorScheme.tertiaryContainer.withValues(alpha: 0.4), height: 88,
shape: BoxShape.circle, decoration: BoxDecoration(
), shape: BoxShape.circle,
child: Icon( color: scheme.tertiary.withValues(alpha: 0.10),
Icons.browser_not_supported_rounded, border: Border.all(
size: 80, color: scheme.tertiary.withValues(alpha: 0.25),
color: colorScheme.tertiary, width: 1.5,
),
),
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,
), ),
), ),
], 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,
),
),
],
),
),
],
), ),
], ),
), ),
), ),
); );
+223 -66
View File
@@ -1,14 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../helpers/snack_bar_builder.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/community.dart'; import '../models/community.dart';
import '../storage/community_store.dart'; import '../storage/community_store.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/qr_scanner_widget.dart'; import '../widgets/qr_scanner_widget.dart';
import '../helpers/snack_bar_builder.dart';
/// Screen for scanning community QR codes to join communities. /// Screen for scanning community QR codes to join communities.
/// ///
@@ -35,16 +39,90 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
centerTitle: true, centerTitle: true,
), ),
body: _isProcessing body: _isProcessing
? const Center(child: CircularProgressIndicator()) ? Container(
color: Theme.of(context).colorScheme.surface,
child: const Center(child: CircularProgressIndicator()),
)
: QrScannerWidget( : QrScannerWidget(
onScanned: (data) => _handleScannedData(context, data), onScanned: (data) => _handleScannedData(context, data),
validator: Community.isValidQrData, validator: Community.isValidQrData,
onValidationFailed: (_) => _showInvalidQrError(context), onValidationFailed: (_) => _showInvalidQrError(context),
instructions: context.l10n.community_scanInstructions, 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<void> _handleScannedData(BuildContext context, String data) async { Future<void> _handleScannedData(BuildContext context, String data) async {
if (_isProcessing) return; if (_isProcessing) return;
@@ -80,7 +158,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.community_invalidQrCode), content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.red, backgroundColor: MeshPalette.alert,
); );
} }
} finally { } finally {
@@ -96,29 +174,74 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.community_invalidQrCode), content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.orange, backgroundColor: MeshPalette.warn,
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
); );
} }
void _showAlreadyMemberDialog(BuildContext context, Community community) { void _showAlreadyMemberDialog(BuildContext context, Community community) {
showDialog( showMeshSheet(
context: context, context,
builder: (dialogContext) => AlertDialog( builder: (sheetContext) {
title: Text(context.l10n.community_alreadyMember), final sheetScheme = Theme.of(sheetContext).colorScheme;
content: Text( return Column(
context.l10n.community_alreadyMemberMessage(community.name), mainAxisSize: MainAxisSize.min,
), crossAxisAlignment: CrossAxisAlignment.stretch,
actions: [ children: [
TextButton( BottomSheetHeader(title: context.l10n.community_alreadyMember),
onPressed: () { Padding(
Navigator.pop(dialogContext); padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
Navigator.pop(context); child: Text(
}, context.l10n.community_alreadyMemberMessage(community.name),
child: Text(context.l10n.common_ok), 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<CommunityQrScannerScreen> {
Community community, Community community,
) async { ) async {
bool addPublicChannel = true; bool addPublicChannel = true;
final completer = Completer<bool>();
final result = await showDialog<bool>( await showMeshSheet<void>(
context: context, context,
builder: (dialogContext) => StatefulBuilder( builder: (sheetContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog( builder: (sheetContext, setSheetState) {
title: Text(context.l10n.community_joinTitle), final joinScheme = Theme.of(sheetContext).colorScheme;
content: Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text(context.l10n.community_joinConfirmation(community.name)), BottomSheetHeader(title: context.l10n.community_joinTitle),
const SizedBox(height: 16), Padding(
Row( 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: [ children: [
Icon( AvatarCircle(
Icons.groups, name: community.name,
color: Theme.of(dialogContext).colorScheme.primary, icon: Icons.groups,
color: MeshPalette.magenta,
size: 44,
), ),
const SizedBox(width: 12), const SizedBox(width: 14),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
community.name, community.name,
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
), ),
Text( Text(
'ID: ${community.shortCommunityId}...', 'ID: ${community.shortCommunityId}...',
style: TextStyle( style: MeshTheme.mono(
fontSize: 12, fontSize: 11.5,
color: Colors.grey[600], color: joinScheme.onSurfaceVariant,
), ),
), ),
], ],
@@ -166,38 +302,59 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
), ),
], ],
), ),
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( CheckboxListTile(
onPressed: () => Navigator.pop(dialogContext, true), value: addPublicChannel,
child: Text(context.l10n.community_join), 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); await _joinCommunity(context, community, addPublicChannel);
} else if (context.mounted) { } else if (context.mounted) {
// User cancelled - go back // User cancelled - go back
@@ -231,7 +388,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.community_joined(community.name)), content: Text(context.l10n.community_joined(community.name)),
backgroundColor: Colors.green, backgroundColor: MeshPalette.signal,
); );
// Return to previous screen // Return to previous screen
+114 -29
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/models/companion_radio_stats.dart'; import 'package:meshcore_open/models/companion_radio_stats.dart';
import 'package:meshcore_open/l10n/l10n.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'; import 'package:provider/provider.dart';
class CompanionRadioStatsScreen extends StatefulWidget { class CompanionRadioStatsScreen extends StatefulWidget {
@@ -49,6 +51,25 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
@@ -85,44 +106,108 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
valueListenable: connector.radioStatsNotifier, valueListenable: connector.radioStatsNotifier,
builder: (context, stats, _) { builder: (context, stats, _) {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(vertical: 8),
children: [ children: [
if (stats != null) ...[ if (stats != null) ...[
Text( const SectionHeader(
l10n.radioStats_noiseFloor(stats.noiseFloorDbm), 'Signal',
style: tt.titleMedium, padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
), ),
const SizedBox(height: 4), MeshCard(
Text(l10n.radioStats_lastRssi(stats.lastRssiDbm)), margin: const EdgeInsets.symmetric(
Text( horizontal: 16,
l10n.radioStats_lastSnr( vertical: 4,
stats.lastSnrDb.toStringAsFixed(1), ),
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)), const SectionHeader(
Text(l10n.radioStats_rxAir(stats.rxAirSecs)), 'Airtime',
const SizedBox(height: 16), padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
] else ),
Text(l10n.radioStats_waiting), MeshCard(
const SizedBox(height: 16), margin: const EdgeInsets.symmetric(
SizedBox( horizontal: 16,
height: 200, vertical: 4,
child: CustomPaint( ),
painter: _NoiseChartPainter( padding: const EdgeInsets.all(4),
samples: List<double>.from(_noiseHistory), child: Column(
colorScheme: scheme, crossAxisAlignment: CrossAxisAlignment.start,
textTheme: tt, 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<double>.from(_noiseHistory),
colorScheme: scheme,
textTheme: tt,
),
child: const SizedBox.expand(),
), ),
child: const SizedBox.expand(),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(
l10n.radioStats_chartCaption,
style: tt.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
], ],
); );
}, },
+225 -89
View File
@@ -16,6 +16,7 @@ import '../models/contact.dart';
import '../l10n/contact_localization.dart'; import '../l10n/contact_localization.dart';
import '../models/contact_group.dart'; import '../models/contact_group.dart';
import '../services/ui_view_state_service.dart'; import '../services/ui_view_state_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../storage/contact_group_store.dart'; import '../storage/contact_group_store.dart';
import '../utils/dialog_utils.dart'; import '../utils/dialog_utils.dart';
@@ -24,12 +25,12 @@ import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import '../widgets/repeater_login_dialog.dart'; import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart'; import '../widgets/room_login_dialog.dart';
import '../widgets/sync_progress_overlay.dart'; import '../widgets/sync_progress_overlay.dart';
import '../widgets/unread_badge.dart'; import '../widgets/unread_badge.dart';
import '../helpers/contact_ui.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
@@ -472,12 +473,13 @@ class _ContactsScreenState extends State<ContactsScreen>
} }
void _showAddContactSheet(BuildContext context) { void _showAddContactSheet(BuildContext context) {
showModalBottomSheet( showMeshSheet(
context: context, context,
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
BottomSheetHeader(title: context.l10n.contacts_title),
ListTile( ListTile(
leading: const Icon(Icons.paste), leading: const Icon(Icons.paste),
title: Text(context.l10n.contacts_addContactFromClipboard), title: Text(context.l10n.contacts_addContactFromClipboard),
@@ -499,6 +501,7 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
}, },
), ),
const SizedBox(height: 8),
], ],
), ),
), ),
@@ -909,7 +912,8 @@ class _ContactsScreenState extends State<ContactsScreen>
final unreadCount = connector.getUnreadCountForContact( final unreadCount = connector.getUnreadCountForContact(
contact, contact,
); );
return _ContactTile( return _ContactTileEntrance(
index: index,
contact: contact, contact: contact,
lastSeen: _resolveLastSeen(contact), lastSeen: _resolveLastSeen(contact),
unreadCount: unreadCount, unreadCount: unreadCount,
@@ -1343,17 +1347,22 @@ class _ContactsScreenState extends State<ContactsScreen>
final isRoom = contact.type == advTypeRoom; final isRoom = contact.type == advTypeRoom;
final isFavorite = contact.isFavorite; final isFavorite = contact.isFavorite;
showModalBottomSheet( showMeshSheet(
context: context, context,
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
BottomSheetHeader(
title: contact.name,
subtitle: contact.typeLabel(context.l10n),
),
if (isRepeater) ...[ if (isRepeater) ...[
ListTile( ListTile(
leading: const Icon(Icons.radar, color: Colors.green), leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_ping), title: Text(context.l10n.contacts_ping),
onTap: () { onTap: () {
Navigator.pop(sheetContext);
final hw = context final hw = context
.read<MeshCoreConnector>() .read<MeshCoreConnector>()
.pathHashByteWidth; .pathHashByteWidth;
@@ -1371,7 +1380,7 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange), leading: Icon(Icons.cell_tower, color: MeshPalette.warn),
title: Text(context.l10n.contacts_manageRepeater), title: Text(context.l10n.contacts_manageRepeater),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
@@ -1380,9 +1389,10 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
] else if (isRoom) ...[ ] else if (isRoom) ...[
ListTile( ListTile(
leading: const Icon(Icons.radar, color: Colors.green), leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_pathTrace), title: Text(context.l10n.contacts_pathTrace),
onTap: () { onTap: () {
Navigator.pop(sheetContext);
final hw = context final hw = context
.read<MeshCoreConnector>() .read<MeshCoreConnector>()
.pathHashByteWidth; .pathHashByteWidth;
@@ -1405,7 +1415,7 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.room, color: Colors.blue), leading: Icon(Icons.meeting_room, color: MeshPalette.blue),
title: Text(context.l10n.contacts_roomLogin), title: Text(context.l10n.contacts_roomLogin),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
@@ -1413,10 +1423,7 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
), ),
ListTile( ListTile(
leading: const Icon( leading: Icon(Icons.room_preferences, color: MeshPalette.warn),
Icons.room_preferences,
color: Colors.orange,
),
title: Text(context.l10n.room_management), title: Text(context.l10n.room_management),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
@@ -1430,9 +1437,10 @@ class _ContactsScreenState extends State<ContactsScreen>
] else ...[ ] else ...[
if (contact.pathLength > 0) if (contact.pathLength > 0)
ListTile( ListTile(
leading: const Icon(Icons.radar, color: Colors.green), leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_chatTraceRoute), title: Text(context.l10n.contacts_chatTraceRoute),
onTap: () { onTap: () {
Navigator.pop(sheetContext);
final hw = context final hw = context
.read<MeshCoreConnector>() .read<MeshCoreConnector>()
.pathHashByteWidth; .pathHashByteWidth;
@@ -1456,7 +1464,7 @@ class _ContactsScreenState extends State<ContactsScreen>
ListTile( ListTile(
leading: Icon( leading: Icon(
isFavorite ? Icons.star : Icons.star_border, isFavorite ? Icons.star : Icons.star_border,
color: Colors.amber[700], color: MeshPalette.warn,
), ),
title: Text( title: Text(
isFavorite isFavorite
@@ -1501,6 +1509,7 @@ class _ContactsScreenState extends State<ContactsScreen>
_confirmDelete(context, connector, contact); _confirmDelete(context, connector, contact);
}, },
), ),
const SizedBox(height: 8),
], ],
), ),
), ),
@@ -1555,82 +1564,173 @@ class _ContactTile extends StatelessWidget {
required this.onLongPress, required this.onLongPress,
}); });
@override /// Node-type avatar color per design language.
Widget build(BuildContext context) { Color _avatarColor() {
return GestureDetector( switch (contact.type) {
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null, case advTypeRepeater:
child: ListTile( return MeshPalette.warn;
leading: CircleAvatar( case advTypeRoom:
backgroundColor: contactTypeColor(contact.type), return MeshPalette.magenta;
child: _buildContactAvatar(contact), case advTypeSensor:
), return const Color(0xFF4ACCC4); // teal
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis), default:
subtitle: Text( return MeshPalette.blue; // chat AvatarCircle handles deterministic hue
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,
),
);
} }
Widget _buildContactAvatar(Contact contact) { /// Node-type avatar icon. Returns null for chat nodes so AvatarCircle shows initials.
final emoji = firstEmoji(contact.name); IconData? _avatarIcon() {
if (emoji != null) { switch (contact.type) {
return Text(emoji, style: const TextStyle(fontSize: 18)); 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) { String _formatLastSeen(BuildContext context, DateTime lastSeen) {
@@ -1655,3 +1755,39 @@ class _ContactTile extends StatelessWidget {
: context.l10n.contacts_lastSeenDaysAgo(days); : 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,
),
);
}
}
+189 -109
View File
@@ -7,12 +7,14 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../l10n/contact_localization.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../theme/mesh_theme.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../widgets/app_bar.dart'; import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart'; import '../widgets/list_filter_widget.dart';
import '../helpers/contact_ui.dart'; import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
enum DiscoverySortOption { lastSeen, name, type } enum DiscoverySortOption { lastSeen, name, type }
@@ -47,6 +49,34 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
: contact.lastSeen; : 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
@@ -93,121 +123,167 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
children: [ children: [
_buildFilters(filteredAndSorted, connector), _buildFilters(filteredAndSorted, connector),
Expanded( Expanded(
child: discoveredContacts.isEmpty child: AnimatedSwitcher(
? Center(child: Text(l10n.contacts_noContacts)) duration: const Duration(milliseconds: 220),
: filteredAndSorted.isEmpty child: discoveredContacts.isEmpty
? Center(child: Text(l10n.discoveredContacts_noMatching)) ? Center(
: ListView.builder( key: const ValueKey('empty_all'),
itemCount: filteredAndSorted.length, child: Text(l10n.contacts_noContacts),
itemBuilder: (context, index) { )
final contact = filteredAndSorted[index]; : filteredAndSorted.isEmpty
final tile = ListTile( ? Center(
leading: CircleAvatar( key: const ValueKey('empty_filtered'),
backgroundColor: contactTypeColor(contact.type), child: Text(l10n.discoveredContacts_noMatching),
child: Icon( )
contactTypeIcon(contact.type), : ListView.builder(
color: Colors.white, key: const ValueKey('list'),
size: 20, padding: const EdgeInsets.only(bottom: 24),
), itemCount: filteredAndSorted.length,
), itemBuilder: (context, index) {
title: Text( 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, contact.name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, 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, contact.shortPubKeyHex,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), style: MeshTheme.mono(
// Clamp text scaling in trailing section to prevent overflow while fontSize: 11,
// maintaining accessibility. Primary content (title/subtitle) scales normally. color: scheme.onSurfaceVariant,
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),
),
],
),
],
),
), ),
), ),
onTap: () { ),
connector.importDiscoveredContact(contact); if (contact.hasLocation) ...[
showDismissibleSnackBar( const SizedBox(width: 6),
context, Icon(
content: Text( Icons.location_on,
context.l10n.discoveredContacts_contactAdded, size: 13,
), color: scheme.onSurfaceVariant.withValues(alpha: 0.55),
action: SnackBarAction( ),
label: context.l10n.common_undo, ],
onPressed: () => connector.removeContact(contact), if (contact.rawPacket != null) ...[
), const SizedBox(width: 4),
); Icon(
}, Icons.cell_tower,
onLongPress: () => size: 13,
_showContactContextMenu(contact, connector), color: scheme.onSurfaceVariant.withValues(alpha: 0.55),
); ),
if (PlatformInfo.isDesktop) { ],
return GestureDetector( ],
onSecondaryTapUp: (_) =>
_showContactContextMenu(contact, connector),
child: tile,
);
}
return tile;
},
), ),
), ],
], ),
),
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<DiscoveryScreen> {
Contact contact, Contact contact,
MeshCoreConnector connector, MeshCoreConnector connector,
) async { ) async {
final action = await showModalBottomSheet<String>( final action = await showMeshSheet<String>(
context: context, context,
showDragHandle: true,
builder: (sheetContext) { builder: (sheetContext) {
final l10n = context.l10n; final l10n = context.l10n;
return SafeArea( return SafeArea(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
BottomSheetHeader(
title: contact.name,
subtitle: contact.typeLabel(l10n),
),
ListTile( ListTile(
leading: const Icon(Icons.copy), leading: const Icon(Icons.copy),
title: Text(l10n.discoveredContacts_copyContact), title: Text(l10n.discoveredContacts_copyContact),
@@ -235,6 +314,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
title: Text(l10n.discoveredContacts_deleteContact), title: Text(l10n.discoveredContacts_deleteContact),
onTap: () => Navigator.of(sheetContext).pop('delete_contact'), onTap: () => Navigator.of(sheetContext).pop('delete_contact'),
), ),
const SizedBox(height: 8),
], ],
), ),
); );
File diff suppressed because it is too large Load Diff
+166 -128
View File
@@ -10,6 +10,9 @@ import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.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 { class MapCacheScreen extends StatefulWidget {
const MapCacheScreen({super.key}); const MapCacheScreen({super.key});
@@ -76,27 +79,34 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
return Positioned( return Positioned(
top: 12, top: 12,
left: 12, left: 12,
child: Card( child: DecoratedBox(
elevation: 4, decoration: BoxDecoration(
child: Column( color: MeshPalette.bg1.withValues(alpha: 0.90),
mainAxisSize: MainAxisSize.min, borderRadius: BorderRadius.circular(MeshRadii.md),
children: [ border: Border.all(color: MeshPalette.line2),
IconButton( ),
icon: const Icon(Icons.add), child: ClipRRect(
tooltip: context.l10n.map_zoomIn, borderRadius: BorderRadius.circular(MeshRadii.md),
onPressed: () => _zoomMapBy(1), child: Column(
), mainAxisSize: MainAxisSize.min,
IconButton( children: [
icon: const Icon(Icons.remove), IconButton(
tooltip: context.l10n.map_zoomOut, icon: const Icon(Icons.add),
onPressed: () => _zoomMapBy(-1), tooltip: context.l10n.map_zoomIn,
), onPressed: () => _zoomMapBy(1),
IconButton( ),
icon: const Icon(Icons.my_location), IconButton(
tooltip: context.l10n.map_centerMap, icon: const Icon(Icons.remove),
onPressed: _resetMapView, 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<MapCacheScreen> {
final tileCache = context.read<MapTileCacheService>(); final tileCache = context.read<MapTileCacheService>();
final selectedBounds = _selectedBounds; final selectedBounds = _selectedBounds;
final l10n = context.l10n; final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
final progressValue = _estimatedTiles == 0 final progressValue = _estimatedTiles == 0
? 0.0 ? 0.0
@@ -318,13 +329,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
), ),
), ),
children: [ children: [
TileLayer( ThemedMapTileLayer(tileCache: tileCache),
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (selectedBounds != null) if (selectedBounds != null)
PolygonLayer( PolygonLayer(
polygons: [ polygons: [
@@ -342,14 +347,25 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Positioned( Positioned(
top: 12, top: 12,
right: 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( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text( child: Text(
selectedBounds == null selectedBounds == null
? l10n.mapCache_noAreaSelected ? l10n.mapCache_noAreaSelected
: _formatBounds(selectedBounds, l10n), : _formatBounds(selectedBounds, l10n),
style: const TextStyle(fontSize: 12), style: MeshTheme.mono(
fontSize: 11,
color: MeshPalette.ink2,
),
), ),
), ),
), ),
@@ -359,111 +375,133 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
), ),
SafeArea( SafeArea(
top: false, top: false,
child: Padding( child: DecoratedBox(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), decoration: BoxDecoration(
child: Column( color: scheme.surfaceContainerLow,
crossAxisAlignment: CrossAxisAlignment.start, border: Border(top: BorderSide(color: scheme.outlineVariant)),
mainAxisSize: MainAxisSize.min, ),
children: [ child: Padding(
Text( padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
l10n.mapCache_cacheArea, child: Column(
style: const TextStyle( crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.bold, mainAxisSize: MainAxisSize.min,
fontSize: 16, children: [
SectionHeader(
l10n.mapCache_cacheArea,
padding: const EdgeInsets.fromLTRB(0, 12, 0, 8),
), ),
), Row(
const SizedBox(height: 8), children: [
Row( Expanded(
children: [ child: ElevatedButton.icon(
Expanded( icon: const Icon(Icons.crop_free),
child: ElevatedButton.icon( label: Text(l10n.mapCache_useCurrentView),
icon: const Icon(Icons.crop_free), onPressed: _isDownloading
label: Text(l10n.mapCache_useCurrentView), ? null
onPressed: _isDownloading ? null : _setBoundsFromView, : _setBoundsFromView,
),
), ),
), const SizedBox(width: 12),
const SizedBox(width: 12), TextButton(
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),
onPressed: _isDownloading || selectedBounds == null onPressed: _isDownloading || selectedBounds == null
? 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), min: 3,
OutlinedButton( max: 18,
onPressed: _isDownloading ? null : _clearCache, divisions: 15,
child: Text(l10n.mapCache_clearCacheButton), labels: RangeLabels('$_minZoom', '$_maxZoom'),
), onChanged: _isDownloading
], ? null
), : (values) {
if (_failedTiles > 0 && !_isDownloading) setState(() {
Padding( _minZoom = values.start.round();
padding: const EdgeInsets.only(top: 8), _maxZoom = values.end.round();
child: Text( });
l10n.mapCache_failedDownloads(_failedTiles), },
style: TextStyle( onChangeEnd: _isDownloading
color: Theme.of(context).colorScheme.error, ? 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,
),
),
),
],
),
), ),
), ),
), ),
+1711 -643
View File
File diff suppressed because it is too large Load Diff
+89 -71
View File
@@ -9,9 +9,10 @@ import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/routing_sheet.dart'; import '../widgets/routing_sheet.dart';
import '../widgets/snr_indicator.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
class NeighborsScreen extends StatefulWidget { class NeighborsScreen extends StatefulWidget {
@@ -321,7 +322,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: _loadNeighbors, onRefresh: _loadNeighbors,
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [ children: [
if (!_isLoaded && if (!_isLoaded &&
!_hasData && !_hasData &&
@@ -330,9 +331,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
if (_isLoaded || if (_isLoaded ||
_hasData && _hasData &&
!(_parsedNeighbors == null || _parsedNeighbors!.isEmpty)) !(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
_buildNeighborsInfoCard( _buildNeighborsList(connector),
"${l10n.repeater_neighbors} - $_neighborCount",
),
], ],
), ),
), ),
@@ -340,81 +339,100 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
); );
} }
Widget _buildNeighborsInfoCard(String title) { Widget _buildNeighborsList(MeshCoreConnector connector) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final l10n = context.l10n;
return Card( return Column(
child: Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.all(16), children: [
child: Column( SectionHeader(
crossAxisAlignment: CrossAxisAlignment.start, '${l10n.repeater_neighbors}$_neighborCount',
children: [ padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
Row( ),
for (var i = 0; i < _parsedNeighbors!.length; i++)
ListEntrance(
index: i,
child: _buildNeighborRow(
_parsedNeighbors![i],
connector.currentSf,
),
),
],
);
}
Widget _buildNeighborRow(Map<String, dynamic> 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: [ children: [
Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text( Text(
title, name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w500,
fontWeight: FontWeight.bold, 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) const SizedBox(width: 10),
_buildInfoRow( Column(
entry.value['contact'] != null crossAxisAlignment: CrossAxisAlignment.end,
? entry.value['contact'].name mainAxisSize: MainAxisSize.min,
: context.l10n.neighbors_unknownContact( children: [
"<${pubKeyToHex(entry.value['publicKey'])}>", SignalBars(snr: snr, height: 16),
), const SizedBox(height: 4),
context.l10n.neighbors_heardAgo( Text(
fmtDuration(entry.value['lastHeard'] + 0.0), '${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),
),
],
),
),
), ),
], ],
), ),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+221 -236
View File
@@ -1,10 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../l10n/contact_localization.dart'; import '../l10n/contact_localization.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'repeater_status_screen.dart'; import 'repeater_status_screen.dart';
import 'repeater_cli_screen.dart'; import 'repeater_cli_screen.dart';
import 'repeater_settings_screen.dart'; import 'repeater_settings_screen.dart';
@@ -26,189 +29,157 @@ class RepeaterHubScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final settingsService = context.watch<AppSettingsService>(); final settingsService = context.watch<AppSettingsService>();
final chemistry = settingsService.batteryChemistryForRepeater( final chemistry = settingsService.batteryChemistryForRepeater(
repeater.publicKeyHex, repeater.publicKeyHex,
); );
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Column( title: Text(
crossAxisAlignment: CrossAxisAlignment.start, repeater.type == advTypeRepeater
mainAxisSize: MainAxisSize.min, ? (isAdmin ? l10n.repeater_management : l10n.repeater_guest)
children: [ : (isAdmin ? l10n.room_management : l10n.room_guest),
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,
),
),
],
), ),
centerTitle: false, centerTitle: true,
), ),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.only(bottom: 24),
children: [ children: [
// Repeater info card // Identity card
Card( Padding(
child: Padding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
padding: const EdgeInsets.all(16), child: MeshCard(
child: Column( margin: EdgeInsets.zero,
padding: const EdgeInsets.all(20),
child: Row(
children: [ children: [
CircleAvatar( AvatarCircle(
radius: 40, name: repeater.name,
backgroundColor: Theme.of( size: 52,
context, color: MeshPalette.warn,
).colorScheme.tertiaryContainer, icon: Icons.cell_tower,
child: Icon(
Icons.cell_tower,
size: 40,
color: Theme.of(
context,
).colorScheme.onTertiaryContainer,
),
), ),
const SizedBox(height: 16), const SizedBox(width: 16),
Text( Expanded(
repeater.name, child: Column(
style: const TextStyle( crossAxisAlignment: CrossAxisAlignment.start,
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,
children: [ children: [
Icon(
Icons.location_on,
size: 14,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text( Text(
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}', repeater.name,
style: TextStyle( style: Theme.of(context)
fontSize: 12, .textTheme
color: Theme.of( .titleMedium
context, ?.copyWith(fontWeight: FontWeight.w700),
).colorScheme.onSurfaceVariant, 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) // Battery chemistry (admin only)
Card( if (isAdmin) ...[
child: Padding( SectionHeader(l10n.appSettings_batteryChemistry),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), MeshCard(
child: Column( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.fromLTRB(14, 10, 14, 14),
children: [ child: DropdownButtonFormField<String>(
Row( initialValue: chemistry,
children: [ isExpanded: true,
const Icon(Icons.battery_full), decoration: InputDecoration(
const SizedBox(width: 10), prefixIcon: const Icon(Icons.battery_full, size: 18),
Expanded( labelText: l10n.appSettings_batteryChemistry,
child: Text(
l10n.appSettings_batteryChemistry,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
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),
),
],
),
],
), ),
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 // Tools
? l10n.repeater_managementTools SectionHeader(
: l10n.repeater_guestTools, isAdmin ? l10n.repeater_managementTools : l10n.repeater_guestTools,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 16),
// Status button _HubActionTile(
_buildManagementCard( index: 0,
context,
icon: Icons.analytics, icon: Icons.analytics,
title: l10n.repeater_status, title: l10n.repeater_status,
subtitle: l10n.repeater_statusSubtitle, subtitle: l10n.repeater_statusSubtitle,
color: Theme.of(context).colorScheme.primary, accentColor: MeshPalette.blue,
onTap: () { onTap: () {
HapticFeedback.selectionClick();
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -220,15 +191,15 @@ class RepeaterHubScreen extends StatelessWidget {
); );
}, },
), ),
const SizedBox(height: 16),
// Telemetry button _HubActionTile(
_buildManagementCard( index: 1,
context,
icon: Icons.bar_chart_sharp, icon: Icons.bar_chart_sharp,
title: l10n.repeater_telemetry, title: l10n.repeater_telemetry,
subtitle: l10n.repeater_telemetrySubtitle, subtitle: l10n.repeater_telemetrySubtitle,
color: Theme.of(context).colorScheme.secondary, accentColor: MeshPalette.magenta,
onTap: () { onTap: () {
HapticFeedback.selectionClick();
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -237,16 +208,34 @@ class RepeaterHubScreen extends StatelessWidget {
); );
}, },
), ),
if (isAdmin) const SizedBox(height: 12),
// CLI button _HubActionTile(
if (isAdmin) index: 2,
_buildManagementCard( icon: Icons.group,
context, 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, icon: Icons.terminal,
title: l10n.repeater_cli, title: l10n.repeater_cli,
subtitle: l10n.repeater_cliSubtitle, subtitle: l10n.repeater_cliSubtitle,
color: Theme.of(context).colorScheme.tertiary, accentColor: MeshPalette.warn,
onTap: () { onTap: () {
HapticFeedback.selectionClick();
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -258,34 +247,14 @@ class RepeaterHubScreen extends StatelessWidget {
); );
}, },
), ),
const SizedBox(height: 12), _HubActionTile(
// Neighbors button index: 4,
_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,
icon: Icons.settings, icon: Icons.settings,
title: l10n.repeater_settings, title: l10n.repeater_settings,
subtitle: l10n.repeater_settingsSubtitle, subtitle: l10n.repeater_settingsSubtitle,
color: Theme.of(context).colorScheme.error, accentColor: MeshPalette.alert,
onTap: () { onTap: () {
HapticFeedback.selectionClick();
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -297,66 +266,82 @@ class RepeaterHubScreen extends StatelessWidget {
); );
}, },
), ),
],
], ],
), ),
), ),
); );
} }
}
Widget _buildManagementCard( class _HubActionTile extends StatelessWidget {
BuildContext context, { final int index;
required IconData icon, final IconData icon;
required String title, final String title;
required String subtitle, final String subtitle;
required Color color, final Color accentColor;
required VoidCallback onTap, final VoidCallback onTap;
}) {
return Card( const _HubActionTile({
elevation: 2, required this.index,
child: InkWell( 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, onTap: onTap,
borderRadius: BorderRadius.circular(12), child: Row(
child: Padding( children: [
padding: const EdgeInsets.all(16), Container(
child: Row( width: 44,
children: [ height: 44,
Container( decoration: BoxDecoration(
padding: const EdgeInsets.all(12), color: accentColor.withValues(alpha: 0.12),
decoration: BoxDecoration( borderRadius: BorderRadius.circular(MeshRadii.md),
color: color.withValues(alpha: 0.1), border: Border.all(
borderRadius: BorderRadius.circular(12), color: accentColor.withValues(alpha: 0.3),
),
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,
),
),
],
), ),
), ),
Icon( alignment: Alignment.center,
Icons.chevron_right, child: Icon(icon, size: 22, color: accentColor),
color: Theme.of(context).colorScheme.onSurfaceVariant, ),
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,
),
],
), ),
), ),
); );
File diff suppressed because it is too large Load Diff
+222 -236
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -10,7 +11,9 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/battery_utils.dart'; import '../utils/battery_utils.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/routing_sheet.dart'; import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
@@ -64,8 +67,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector); _commandService = RepeaterCommandService(connector);
_setupMessageListener(); _setupMessageListener();
// Defer until after the first frame so any notifyListeners() triggered
// during preparePathForContactSend doesn't fire mid-build.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadStatus(); if (mounted) _loadStatus();
}); });
@@ -81,12 +82,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
void _setupMessageListener() { void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) { _frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return; if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == pushCodeStatusResponse) { if (frame[0] == pushCodeStatusResponse) {
_handleStatusResponse(frame); _handleStatusResponse(frame);
} else if (frame[0] == respCodeContactMsgRecv || } else if (frame[0] == respCodeContactMsgRecv ||
@@ -118,11 +115,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final parsed = parseContactMessageText(frame); final parsed = parseContactMessageText(frame);
if (parsed == null) return; if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return; if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text); _commandService?.handleResponse(widget.repeater, parsed.text);
// Parse status responses
_parseStatusResponse(parsed.text); _parseStatusResponse(parsed.text);
_recordStatusResult(true); _recordStatusResult(true);
} }
@@ -131,7 +124,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
if (frame.length < 8) return; if (frame.length < 8) return;
final prefix = frame.sublist(2, 8); final prefix = frame.sublist(2, 8);
if (!_matchesRepeaterPrefix(prefix)) return; if (!_matchesRepeaterPrefix(prefix)) return;
if (frame.length < _statusResponseBytes) return; if (frame.length < _statusResponseBytes) return;
final data = ByteData.sublistView( final data = ByteData.sublistView(
@@ -254,14 +246,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_dupFlood = _asInt(data['dup_flood']); _dupFlood = _asInt(data['dup_flood']);
_dupDirect = _asInt(data['dup_direct']); _dupDirect = _asInt(data['dup_direct']);
} }
} catch (_) { } catch (_) {}
// Ignore parse failures for non-JSON responses.
}
}
if (mounted) {
setState(() {});
} }
if (mounted) setState(() {});
} }
Future<void> _loadStatus() async { Future<void> _loadStatus() async {
@@ -302,9 +289,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
var messageBytes = frame.length >= _statusResponseBytes var messageBytes = frame.length >= _statusResponseBytes
? frame.length ? frame.length
: _statusResponseBytes; : _statusResponseBytes;
if (messageBytes < maxFrameSize) { if (messageBytes < maxFrameSize) messageBytes = maxFrameSize;
messageBytes = maxFrameSize;
}
final timeoutMs = connector.calculateTimeout( final timeoutMs = connector.calculateTimeout(
pathLength: pathLengthValue, pathLength: pathLengthValue,
messageBytes: messageBytes, messageBytes: messageBytes,
@@ -312,9 +297,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_statusTimeout?.cancel(); _statusTimeout?.cancel();
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () { _statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() => _isLoading = false);
_isLoading = false;
});
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.repeater_statusRequestTimeout), content: Text(context.l10n.repeater_statusRequestTimeout),
@@ -324,10 +307,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
}); });
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() { setState(() => _isLoading = false);
_isLoading = false;
});
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())), content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
@@ -347,214 +327,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_pendingStatusSelection = null; _pendingStatusSelection = null;
} }
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
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) { int? _asInt(dynamic value) {
if (value == null) return null; if (value == null) return null;
if (value is int) return value; if (value is int) return value;
@@ -661,4 +433,218 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
if (snr == null) return ''; if (snr == null) return '';
return snr.toStringAsFixed(2); return snr.toStringAsFixed(2);
} }
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
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<MeshCoreConnector>();
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,
});
} }
+141 -82
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -7,10 +8,12 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/linux_ble_error_classifier.dart'; import '../services/linux_ble_error_classifier.dart';
import '../theme/mesh_theme.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart'; import '../widgets/device_tile.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'tcp_screen.dart'; import 'tcp_screen.dart';
@@ -136,12 +139,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
builder: (context, connector, child) { builder: (context, connector, child) {
return Column( return Column(
children: [ children: [
// Bluetooth off warning // Bluetooth off warning slides in/out with AnimatedSize
if (_bluetoothState == BluetoothAdapterState.off) AnimatedSize(
_bluetoothOffWarning(context), duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: _bluetoothState == BluetoothAdapterState.off
? _BluetoothOffBanner(
onEnable: PlatformInfo.isAndroid
? () => FlutterBluePlus.turnOn()
: null,
)
: const SizedBox.shrink(),
),
// Status bar // Connection status header
_buildStatusBar(context, connector), _ConnectionStatusHeader(connector: connector),
// Device list // Device list
Expanded(child: _buildDeviceList(context, connector)), Expanded(child: _buildDeviceList(context, connector)),
@@ -158,14 +170,31 @@ class _ScannerScreenState extends State<ScannerScreen> {
return FloatingActionButton.extended( return FloatingActionButton.extended(
heroTag: 'scanner_ble_action', heroTag: 'scanner_ble_action',
onPressed: isBluetoothOff ? null : () => _toggleScan(connector), onPressed: isBluetoothOff
icon: isScanning ? null
? const SizedBox( : () {
width: 20, HapticFeedback.lightImpact();
height: 20, _toggleScan(connector);
child: CircularProgressIndicator(strokeWidth: 2), },
) icon: AnimatedSwitcher(
: const Icon(Icons.bluetooth_searching), 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( label: Text(
isScanning isScanning
? context.l10n.scanner_stop ? context.l10n.scanner_stop
@@ -189,51 +218,6 @@ class _ScannerScreenState extends State<ScannerScreen> {
} }
} }
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) { Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) {
if (connector.scanResults.isEmpty) { if (connector.scanResults.isEmpty) {
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off; final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
@@ -251,7 +235,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
action: (isBluetoothOff || isScanning) action: (isBluetoothOff || isScanning)
? null ? null
: FilledButton.icon( : FilledButton.icon(
onPressed: () => _toggleScan(connector), onPressed: () {
HapticFeedback.lightImpact();
_toggleScan(connector);
},
icon: const Icon(Icons.bluetooth_searching), icon: const Icon(Icons.bluetooth_searching),
label: Text(context.l10n.scanner_scan), label: Text(context.l10n.scanner_scan),
), ),
@@ -259,19 +246,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
} }
final isConnecting = connector.state == MeshCoreConnectionState.connecting; final isConnecting = connector.state == MeshCoreConnectionState.connecting;
return ListView.separated( return ListView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
itemCount: connector.scanResults.length, itemCount: connector.scanResults.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final result = connector.scanResults[index]; final result = connector.scanResults[index];
final deviceId = result.device.remoteId.toString(); final deviceId = result.device.remoteId.toString();
return DeviceTile( return ListEntrance(
scanResult: result, index: index,
isConnecting: isConnecting && _connectingDeviceId == deviceId, child: DeviceTile(
onTap: isConnecting scanResult: result,
? null isConnecting: isConnecting && _connectingDeviceId == deviceId,
: () => _connectToDevice(context, connector, result), onTap: isConnecting
? null
: () => _connectToDevice(context, connector, result),
),
); );
}, },
); );
@@ -413,47 +402,117 @@ class _ScannerScreenState extends State<ScannerScreen> {
); );
return pin; return pin;
} }
}
Widget _bluetoothOffWarning(BuildContext context) { // Private sub-widgets
final errorColor = Theme.of(context).colorScheme.error;
return Container( /// Bluetooth-off warning banner styled as an alert MeshCard.
width: double.infinity, class _BluetoothOffBanner extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), final VoidCallback? onEnable;
color: errorColor.withValues(alpha: 0.15),
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( child: Row(
children: [ children: [
Icon(Icons.bluetooth_disabled, size: 24, color: errorColor), Icon(Icons.bluetooth_disabled, size: 20, color: scheme.error),
const SizedBox(width: 12), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
context.l10n.scanner_bluetoothOff, context.l10n.scanner_bluetoothOff,
style: TextStyle( style: TextStyle(
color: errorColor, color: scheme.error,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 14, fontSize: 13.5,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 2),
Text( Text(
context.l10n.scanner_bluetoothOffMessage, context.l10n.scanner_bluetoothOffMessage,
style: TextStyle( style: TextStyle(
color: errorColor.withValues(alpha: 0.85), color: scheme.error.withValues(alpha: 0.8),
fontSize: 12, fontSize: 12,
), ),
), ),
], ],
), ),
), ),
if (PlatformInfo.isAndroid) if (onEnable != null) ...[
const SizedBox(width: 8),
TextButton( TextButton(
onPressed: () => FlutterBluePlus.turnOn(), onPressed: onEnable,
child: Text(context.l10n.scanner_enableBluetooth), 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,
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+100 -56
View File
@@ -1,13 +1,16 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'usb_screen.dart'; import 'usb_screen.dart';
@@ -95,15 +98,32 @@ class _TcpScreenState extends State<TcpScreen> {
final isConnecting = final isConnecting =
connector.state == MeshCoreConnectionState.connecting && connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp; connector.activeTransport == MeshCoreTransportType.tcp;
// A running BLE scan must not block TCP connect: connectTcp() stops // Connect is only available from a fully disconnected state
// any active scan before connecting, so the only reason to disable // scanning, connecting, or an active session must settle first.
// the button is a TCP connect already in flight. final isButtonDisabled =
final isButtonDisabled = isConnecting; connector.state != MeshCoreConnectionState.disconnected;
return Column( return ListView(
padding: const EdgeInsets.only(bottom: 32),
children: [ children: [
_buildStatusBar(context, connector), // Status header
_buildTransportLinks(context),
Padding( 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), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -113,7 +133,6 @@ class _TcpScreenState extends State<TcpScreen> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: context.l10n.tcpHostLabel, labelText: context.l10n.tcpHostLabel,
hintText: context.l10n.tcpHostHint, hintText: context.l10n.tcpHostHint,
border: const OutlineInputBorder(),
), ),
enabled: !isConnecting, enabled: !isConnecting,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
@@ -124,7 +143,6 @@ class _TcpScreenState extends State<TcpScreen> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: context.l10n.tcpPortLabel, labelText: context.l10n.tcpPortLabel,
hintText: context.l10n.tcpPortHint, hintText: context.l10n.tcpPortHint,
border: const OutlineInputBorder(),
), ),
enabled: !isConnecting, enabled: !isConnecting,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
@@ -132,7 +150,12 @@ class _TcpScreenState extends State<TcpScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
key: const Key('tcp_connect_button'), key: const Key('tcp_connect_button'),
onPressed: isButtonDisabled ? null : _connectTcp, onPressed: isButtonDisabled
? null
: () {
HapticFeedback.lightImpact();
_connectTcp();
},
icon: isConnecting icon: isConnecting
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
@@ -151,6 +174,39 @@ class _TcpScreenState extends State<TcpScreen> {
], ],
), ),
), ),
// 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<TcpScreen> {
); );
} }
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) { Widget _buildTransportLinks(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -186,52 +276,6 @@ class _TcpScreenState extends State<TcpScreen> {
); );
} }
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<void> _connectTcp() async { Future<void> _connectTcp() async {
if (_connector.state == MeshCoreConnectionState.connecting || if (_connector.state == MeshCoreConnectionState.connecting ||
_connector.state == MeshCoreConnectionState.connected || _connector.state == MeshCoreConnectionState.connected ||
+39 -54
View File
@@ -19,6 +19,8 @@ import '../utils/battery_utils.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import '../widgets/sync_progress_overlay.dart'; import '../widgets/sync_progress_overlay.dart';
import '../widgets/telemetry_location_map.dart'; import '../widgets/telemetry_location_map.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
class TelemetryScreen extends StatefulWidget { class TelemetryScreen extends StatefulWidget {
final Contact contact; final Contact contact;
@@ -319,6 +321,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final settings = context.watch<AppSettingsService>().settings; final settings = context.watch<AppSettingsService>().settings;
final isImperialUnits = settings.unitSystem == UnitSystem.imperial; final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
@@ -387,7 +390,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
l10n.telemetry_noData, l10n.telemetry_noData,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), ),
), ),
), ),
@@ -415,34 +418,21 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
int channel, int channel,
bool isImperialUnits, bool isImperialUnits,
) { ) {
return Card( return Column(
child: Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.all(16), children: [
child: Column( SectionHeader(title, padding: const EdgeInsets.fromLTRB(16, 16, 16, 8)),
crossAxisAlignment: CrossAxisAlignment.start, MeshCard(
children: [ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
Row( child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Icon( children: [
Icons.info_outline, for (final entry in channelData.entries)
color: Theme.of(context).textTheme.headlineSmall?.color, _buildTelemetryField(entry, channel, isImperialUnits),
), ],
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),
],
), ),
), ],
); );
} }
@@ -601,30 +591,19 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
final l10n = context.l10n; final l10n = context.l10n;
final counterText = _autoRefreshCounterText(); final counterText = _autoRefreshCounterText();
return Card( return Column(
child: Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.all(16), children: [
child: Column( SectionHeader(
crossAxisAlignment: CrossAxisAlignment.stretch, l10n.common_autoRefresh,
children: [ padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
Row( ),
children: [ MeshCard(
Icon( padding: const EdgeInsets.all(16),
Icons.autorenew, child: Column(
color: Theme.of(context).textTheme.headlineSmall?.color, crossAxisAlignment: CrossAxisAlignment.stretch,
), children: [
const SizedBox(width: 8), _buildAutoRefreshNumberField(
Text(
l10n.common_autoRefresh,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
_buildAutoRefreshNumberField(
controller: _autoRefreshIntervalController, controller: _autoRefreshIntervalController,
label: l10n.common_interval, label: l10n.common_interval,
min: _autoRefreshMinIntervalSeconds, min: _autoRefreshMinIntervalSeconds,
@@ -684,6 +663,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
], ],
), ),
), ),
],
); );
} }
@@ -913,6 +893,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
} }
Widget _buildInfoRow(String label, String value) { Widget _buildInfoRow(String label, String value) {
final scheme = Theme.of(context).colorScheme;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 6), padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(
@@ -922,7 +903,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
child: Text( child: Text(
label, label,
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
@@ -930,7 +912,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
value, value,
style: const TextStyle(fontWeight: FontWeight.w400), style: MeshTheme.mono(
fontSize: 13,
color: scheme.onSurface,
),
textAlign: TextAlign.end, textAlign: TextAlign.end,
), ),
], ],
+119 -103
View File
@@ -6,10 +6,13 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart'; import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.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 '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'tcp_screen.dart'; import 'tcp_screen.dart';
@@ -97,9 +100,27 @@ class _UsbScreenState extends State<UsbScreen> {
child: Consumer<MeshCoreConnector>( child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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), _buildTransportLinks(context),
// Port list
Expanded(child: _buildPortList(context, connector)), Expanded(child: _buildPortList(context, connector)),
], ],
); );
@@ -132,6 +153,52 @@ class _UsbScreenState extends State<UsbScreen> {
); );
} }
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) { Widget _buildTransportLinks(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -159,105 +226,20 @@ class _UsbScreenState extends State<UsbScreen> {
); );
} }
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) { Widget _buildPortList(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n; final l10n = context.l10n;
if (_isLoadingPorts) { if (_isLoadingPorts) {
return Center( return EmptyState(
child: Column( icon: Icons.usb,
mainAxisAlignment: MainAxisAlignment.center, title: l10n.usbStatus_searching,
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,
),
),
],
),
); );
} }
if (_ports.isEmpty) { if (_ports.isEmpty) {
return Center( return EmptyState(
child: Column( icon: Icons.usb,
mainAxisAlignment: MainAxisAlignment.center, title: l10n.usbScreenEmptyState,
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,
),
),
],
),
); );
} }
@@ -265,10 +247,9 @@ class _UsbScreenState extends State<UsbScreen> {
connector.state == MeshCoreConnectionState.connecting && connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb; connector.activeTransport == MeshCoreTransportType.usb;
return ListView.separated( return ListView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.only(bottom: 32),
itemCount: _ports.length, itemCount: _ports.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final port = _ports[index]; final port = _ports[index];
final displayName = friendlyUsbPortName(port); final displayName = friendlyUsbPortName(port);
@@ -276,15 +257,50 @@ class _UsbScreenState extends State<UsbScreen> {
final showRawName = final showRawName =
rawName != displayName && !rawName.startsWith('web:'); rawName != displayName && !rawName.startsWith('web:');
return ListTile( return ListEntrance(
leading: const Icon(Icons.usb), index: index,
title: Text( child: MeshCard(
displayName, padding: EdgeInsets.zero,
style: const TextStyle(fontWeight: FontWeight.w500), 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),
); );
}, },
); );
+210 -48
View File
@@ -1,47 +1,48 @@
import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder;
import 'package:flutter/material.dart'; 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 { class MeshPalette {
MeshPalette._(); MeshPalette._();
// Surfaces (cool near-black, slate undertone) // Surfaces shared with the map overlays and navigation.
static const bg = Color(0xFF101417); static const bg = Color(0xFF0B1220);
static const bg1 = Color(0xFF161B1F); static const bg1 = Color(0xFF0F172A);
static const bg2 = Color(0xFF1D242A); static const bg2 = Color(0xFF162033);
static const bg3 = Color(0xFF28313A); static const bg3 = Color(0xFF1E293B);
static const bg4 = Color(0xFF344049); static const bg4 = Color(0xFF334155);
// Lines // Lines
static const line = Color(0xFF222B31); static const line = Color(0xFF1E293B);
static const line2 = Color(0xFF344049); static const line2 = Color(0xFF334155);
static const line3 = Color(0xFF485762); static const line3 = Color(0xFF475569);
// Ink // Ink
static const ink = Color(0xFFE9EEF3); static const ink = Color(0xFFF8FAFC);
static const ink2 = Color(0xFFB5C0C9); static const ink2 = Color(0xFFCBD5E1);
static const ink3 = Color(0xFF7C8A95); static const ink3 = Color(0xFF94A3B8);
static const ink4 = Color(0xFF556470); static const ink4 = Color(0xFF64748B);
// Signal-quality green (used only for SNR coloring, not UI chrome) // Signal-quality green (used only for SNR coloring, not UI chrome)
static const signal = Color(0xFF7BEFA8); static const signal = Color(0xFF22C55E);
static const signalDim = Color(0xFF4DC580); static const signalDim = Color(0xFF16A34A);
// Warn (ember) // Warn
static const warn = Color(0xFFFFA552); static const warn = Color(0xFFF59E0B);
static const warnDim = Color(0xFFC27E3C); static const warnDim = Color(0xFFD97706);
static const warnBg = Color(0x1CFFA552); static const warnBg = Color(0x1FF59E0B);
static const warnLine = Color(0x4DFFA552); static const warnLine = Color(0x66F59E0B);
// Alert (coral) // Alert
static const alert = Color(0xFFFF6A5C); static const alert = Color(0xFFEF4444);
static const alertBg = Color(0x1CFF6A5C); static const alertBg = Color(0x1FEF4444);
static const alertLine = Color(0x52FF6A5C); static const alertLine = Color(0x66EF4444);
// Blue (sky) primary accent // Blue primary map/app accent
static const blue = Color(0xFF7FCBF5); static const blue = Color(0xFF0EA5E9);
static const blueDim = Color(0xFF4A9CC9); static const blueDim = Color(0xFF0284C7);
static const blueBg = Color(0x1C7FCBF5); static const blueBg = Color(0x290EA5E9);
static const blueLine = Color(0x477FCBF5); static const blueLine = Color(0x800EA5E9);
// Magenta // Magenta
static const magenta = Color(0xFFDE7FDB); static const magenta = Color(0xFFDE7FDB);
@@ -49,9 +50,9 @@ class MeshPalette {
static const magentaLine = Color(0x47DE7FDB); static const magentaLine = Color(0x47DE7FDB);
// Me bubble (dusk blue) // Me bubble (dusk blue)
static const me = Color(0xFF1B2C3D); static const me = Color(0xFF0C4A6E);
static const meBorder = Color(0xFF2C4A66); static const meBorder = Color(0xFF0369A1);
static const meInk = Color(0xFFDCE9F5); static const meInk = Color(0xFFF0F9FF);
// Light variant (used when user explicitly picks light theme) // Light variant (used when user explicitly picks light theme)
static const lightBg = Color(0xFFF4F6F8); static const lightBg = Color(0xFFF4F6F8);
@@ -64,6 +65,51 @@ class MeshPalette {
static const lightBlue = Color(0xFF2F6EA8); 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 /// Named font stacks Flutter falls back to system fonts when the named
/// family isn't installed, keeping things working without bundled assets. /// family isn't installed, keeping things working without bundled assets.
class MeshFonts { class MeshFonts {
@@ -72,6 +118,7 @@ class MeshFonts {
static const sans = 'Inter'; static const sans = 'Inter';
static const mono = 'JetBrains Mono'; static const mono = 'JetBrains Mono';
static const display = 'Instrument Serif'; static const display = 'Instrument Serif';
static const emoji = 'Noto Color Emoji';
static const List<String> sansFallback = [ static const List<String> sansFallback = [
'system-ui', 'system-ui',
@@ -93,6 +140,11 @@ class MeshFonts {
'Times New Roman', 'Times New Roman',
'serif', 'serif',
]; ];
static const List<String> emojiFallback = [
'Apple Color Emoji',
'Segoe UI Emoji',
'Noto Emoji',
];
} }
/// Radii used consistently across the app. /// Radii used consistently across the app.
@@ -113,21 +165,21 @@ class MeshTheme {
static ThemeData dark() { static ThemeData dark() {
const scheme = ColorScheme.dark( const scheme = ColorScheme.dark(
primary: MeshPalette.blue, primary: MeshPalette.blue,
onPrimary: Color(0xFF0A1A26), onPrimary: Colors.white,
primaryContainer: MeshPalette.blueBg, primaryContainer: Color(0xFF075985),
onPrimaryContainer: MeshPalette.blue, onPrimaryContainer: Colors.white,
secondary: MeshPalette.magenta, secondary: MeshPalette.magenta,
onSecondary: Color(0xFF201020), onSecondary: Colors.white,
secondaryContainer: Color(0xFF331A33), secondaryContainer: Color(0xFF331A33),
onSecondaryContainer: MeshPalette.magenta, onSecondaryContainer: Colors.white,
tertiary: MeshPalette.warn, tertiary: MeshPalette.warn,
onTertiary: Color(0xFF1F1206), onTertiary: Color(0xFF0B1220),
tertiaryContainer: Color(0xFF3A2710), tertiaryContainer: Color(0xFF78350F),
onTertiaryContainer: Color(0xFFFFC58A), onTertiaryContainer: Colors.white,
error: MeshPalette.alert, error: MeshPalette.alert,
onError: Color(0xFF1A0A08), onError: Colors.white,
errorContainer: MeshPalette.alertBg, errorContainer: Color(0xFF7F1D1D),
onErrorContainer: MeshPalette.alert, onErrorContainer: Colors.white,
surface: MeshPalette.bg, surface: MeshPalette.bg,
onSurface: MeshPalette.ink, onSurface: MeshPalette.ink,
surfaceContainerLowest: MeshPalette.bg, surfaceContainerLowest: MeshPalette.bg,
@@ -334,9 +386,9 @@ class MeshTheme {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
), ),
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surfaceContainerLow, backgroundColor: scheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
indicatorColor: scheme.primary.withValues(alpha: 0.14), indicatorColor: scheme.primary,
indicatorShape: RoundedRectangleBorder( indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.md), borderRadius: BorderRadius.circular(MeshRadii.md),
), ),
@@ -348,13 +400,13 @@ class MeshTheme {
fontSize: 10, fontSize: 10,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500, fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
letterSpacing: 0.1, letterSpacing: 0.1,
color: selected ? scheme.primary : scheme.onSurfaceVariant, color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
); );
}), }),
iconTheme: WidgetStateProperty.resolveWith((states) { iconTheme: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected); final selected = states.contains(WidgetState.selected);
return IconThemeData( return IconThemeData(
color: selected ? scheme.primary : scheme.onSurfaceVariant, color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
size: 22, size: 22,
); );
}), }),
@@ -393,6 +445,106 @@ class MeshTheme {
), ),
iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 22), iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 22),
splashFactory: InkSparkle.splashFactory, 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. /// Color-code an SNR value for consistency across the app.
static Color snrColor(num? snr, {required bool blocked}) { static Color snrColor(num? snr, {required bool blocked}) {
if (blocked) return MeshPalette.alert; if (blocked) return MeshPalette.alert;
+12 -11
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../theme/mesh_theme.dart';
class BatteryUi { class BatteryUi {
final IconData icon; final IconData icon;
@@ -10,19 +11,19 @@ class BatteryUi {
BatteryUi batteryUiForPercent(int? percent) { BatteryUi batteryUiForPercent(int? percent) {
if (percent == null) { if (percent == null) {
return const BatteryUi(Icons.battery_unknown, Colors.grey); return const BatteryUi(Icons.battery_unknown, null);
} }
final p = percent.clamp(0, 100); final p = percent.clamp(0, 100);
return switch (p) { return switch (p) {
<= 5 => const BatteryUi(Icons.battery_alert, Colors.redAccent), <= 5 => const BatteryUi(Icons.battery_alert, MeshPalette.alert),
<= 15 => const BatteryUi(Icons.battery_0_bar, Colors.redAccent), <= 15 => const BatteryUi(Icons.battery_0_bar, MeshPalette.alert),
<= 30 => const BatteryUi(Icons.battery_1_bar, Colors.orange), <= 30 => const BatteryUi(Icons.battery_1_bar, MeshPalette.warn),
<= 45 => const BatteryUi(Icons.battery_2_bar, Colors.amber), <= 45 => const BatteryUi(Icons.battery_2_bar, MeshPalette.warn),
<= 60 => const BatteryUi(Icons.battery_3_bar, Colors.lightGreen), <= 60 => const BatteryUi(Icons.battery_3_bar, null),
<= 80 => const BatteryUi(Icons.battery_5_bar, Colors.green), <= 80 => const BatteryUi(Icons.battery_5_bar, null),
_ => const BatteryUi(Icons.battery_full, Colors.green), _ => const BatteryUi(Icons.battery_full, MeshPalette.signal),
}; };
} }
@@ -76,9 +77,9 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
Flexible( Flexible(
child: Text( child: Text(
displayText, displayText,
style: TextStyle( style: MeshTheme.mono(
fontSize: 12, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w600,
color: batteryUi.color, color: batteryUi.color,
), ),
maxLines: 1, maxLines: 1,
+82 -30
View File
@@ -1,9 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
import 'mesh_ui.dart';
import 'signal_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 { class DeviceTile extends StatelessWidget {
final ScanResult scanResult; final ScanResult scanResult;
final VoidCallback? onTap; final VoidCallback? onTap;
@@ -23,27 +30,10 @@ class DeviceTile extends StatelessWidget {
final name = device.platformName.isNotEmpty final name = device.platformName.isNotEmpty
? device.platformName ? device.platformName
: scanResult.advertisementData.advName; : 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 final tier = rssi >= -60
? 0 ? 0
: rssi >= -70 : rssi >= -70
@@ -55,15 +45,77 @@ class DeviceTile extends StatelessWidget {
: 4; : 4;
final signalUi = signalUiForStrengthTier(tier); final signalUi = signalUiForStrengthTier(tier);
return Column( return MeshCard(
mainAxisAlignment: MainAxisAlignment.center, onTap: onTap == null
children: [ ? null
Icon(signalUi.icon, color: signalUi.color), : () {
Text( HapticFeedback.selectionClick();
'$rssi dBm', onTap!();
style: TextStyle(fontSize: 10, color: signalUi.color), },
), 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,
),
),
],
),
],
),
); );
} }
} }
+60 -23
View File
@@ -29,31 +29,68 @@ class FeatureToggleRow extends StatefulWidget {
class _FeatureToggleRow extends State<FeatureToggleRow> { class _FeatureToggleRow extends State<FeatureToggleRow> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( final scheme = Theme.of(context).colorScheme;
children: [ final textTheme = Theme.of(context).textTheme;
Expanded(
child: SwitchListTile( return Padding(
title: Text(widget.title), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
subtitle: Text(widget.subtitle), child: Row(
value: widget.value, children: [
onChanged: widget.onChanged, Expanded(
contentPadding: EdgeInsets.zero, 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,
),
),
],
),
), ),
), const SizedBox(width: 8),
if (widget.hasRefreshing) Row(
IconButton( mainAxisSize: MainAxisSize.min,
icon: widget.isRefreshing children: [
? const SizedBox( Switch(
width: 18, value: widget.value,
height: 18, onChanged: widget.onChanged,
child: CircularProgressIndicator(strokeWidth: 2), ),
) if (widget.hasRefreshing) ...[
: const Icon(Icons.refresh, size: 20), const SizedBox(width: 4),
onPressed: widget.isRefreshing ? null : widget.onRefresh, widget.isRefreshing
tooltip: widget.refreshTooltip, ? SizedBox(
visualDensity: VisualDensity.compact, 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,
),
),
],
],
), ),
], ],
),
); );
} }
} }
+12 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
class EmojiPicker extends StatelessWidget { class EmojiPicker extends StatelessWidget {
final Function(String) onEmojiSelected; final Function(String) onEmojiSelected;
@@ -257,7 +258,11 @@ class EmojiPicker extends StatelessWidget {
), ),
child: Text( child: Text(
emoji, 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: Center(
child: Text( child: Text(
emojis[index], emojis[index],
style: const TextStyle(fontSize: 28), style: MeshTheme.emoji(),
textHeightBehavior:
const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
), ),
), ),
), ),
+91 -21
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// A centered empty state display with icon, title, and optional subtitle/action. /// 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 IconData icon;
final String title; final String title;
final String? subtitle; final String? subtitle;
@@ -15,29 +17,97 @@ class EmptyState extends StatelessWidget {
this.action, this.action,
}); });
@override
State<EmptyState> createState() => _EmptyStateState();
}
class _EmptyStateState extends State<EmptyState>
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant; final scheme = Theme.of(context).colorScheme;
return Center( return FadeTransition(
child: Column( opacity: _curve,
mainAxisAlignment: MainAxisAlignment.center, child: SlideTransition(
children: [ position: Tween(
Icon(icon, size: 64, color: onSurfaceVariant.withValues(alpha: 0.6)), begin: const Offset(0, 0.06),
const SizedBox(height: 16), end: Offset.zero,
Text(title, style: TextStyle(fontSize: 16, color: onSurfaceVariant)), ).animate(_curve),
if (subtitle != null) ...[ child: Center(
const SizedBox(height: 8), child: Padding(
Text( padding: const EdgeInsets.symmetric(horizontal: 40),
subtitle!, child: Column(
style: TextStyle( mainAxisAlignment: MainAxisAlignment.center,
fontSize: 14, children: [
color: onSurfaceVariant.withValues(alpha: 0.8), Container(
), width: 80,
textAlign: TextAlign.center, 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!], ),
],
), ),
); );
} }
+33 -3
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../helpers/chat_scroll_controller.dart'; import '../helpers/chat_scroll_controller.dart';
import '../theme/mesh_theme.dart';
class JumpToBottomButton extends StatelessWidget { class JumpToBottomButton extends StatelessWidget {
final ChatScrollController scrollController; final ChatScrollController scrollController;
@@ -8,6 +10,7 @@ class JumpToBottomButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: scrollController.showJumpToBottom, valueListenable: scrollController.showJumpToBottom,
builder: (context, show, _) { builder: (context, show, _) {
@@ -15,9 +18,36 @@ class JumpToBottomButton extends StatelessWidget {
return Positioned( return Positioned(
right: 16, right: 16,
bottom: 16, bottom: 16,
child: FloatingActionButton.small( child: Material(
onPressed: scrollController.jumpToBottom, color: Colors.transparent,
child: const Icon(Icons.keyboard_arrow_down), 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,
),
),
),
), ),
); );
}, },
+643
View File
@@ -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<PulseDot> createState() => _PulseDotState();
}
class _PulseDotState extends State<PulseDot>
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<T?> showMeshSheet<T>(
BuildContext context, {
required WidgetBuilder builder,
bool isScrollControlled = true,
}) {
return showModalBottomSheet<T>(
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<ListEntrance> createState() => _ListEntranceState();
}
class _ListEntranceState extends State<ListEntrance>
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,
),
);
}
}
+10 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
class MessageStatusIcon extends StatefulWidget { class MessageStatusIcon extends StatefulWidget {
final bool isAcked; final bool isAcked;
@@ -71,7 +72,11 @@ class _MessageStatusIconState extends State<MessageStatusIcon>
if (widget.isFailed) { if (widget.isFailed) {
return Semantics( return Semantics(
label: l10n.messageStatus_failed, 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<MessageStatusIcon>
: widget.isAcked : widget.isAcked
? l10n.messageStatus_delivered ? l10n.messageStatus_delivered
: l10n.messageStatus_sent; : 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( return Semantics(
label: label, label: label,
+659
View File
@@ -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<Color> kAlternatePathColors = [
Color(0xFF8B5CF6), // purple
MeshPalette.signal, // green
MeshPalette.warn, // amber
MeshPalette.magenta,
];
double getPathDistanceMeters(List<LatLng> 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<Polyline> buildMultiPathPolylines({
required List<DisplayPath> visible,
required DisplayPath? selected,
required bool combined,
required bool animating,
}) {
final lines = <Polyline>[];
if (combined && visible.length > 1) {
final counts = <String, int>{};
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 = <String>{};
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<Polyline> buildPacketTrailPolylines(
PathPlaybackController playback,
Color color,
) {
if (!playback.started || !playback.hasPath) return const [];
final seg = playback.currentSegment;
final traversed = <LatLng>[
...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<Marker> buildPacketMarkers(
PathPlaybackController playback,
Color color,
) {
if (!playback.started || !playback.hasPath) return const [];
final markers = <Marker>[];
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<DisplayPath> paths,
required ValueChanged<DisplayPath> 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<PathViewMode> 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<PathViewMode>(
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<DisplayPath> paths;
final String selectedId;
final Set<String> hiddenIds;
final bool isImperial;
final ValueChanged<DisplayPath> onSelect;
final ValueChanged<DisplayPath> 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 = <String>[
'${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),
),
],
),
),
);
}
}
+18 -9
View File
@@ -2,12 +2,14 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
class QuickSwitchBar extends StatelessWidget { class QuickSwitchBar extends StatelessWidget {
final int selectedIndex; final int selectedIndex;
final ValueChanged<int> onDestinationSelected; final ValueChanged<int> onDestinationSelected;
final int contactsUnreadCount; final int contactsUnreadCount;
final int channelsUnreadCount; final int channelsUnreadCount;
final bool highContrast;
const QuickSwitchBar({ const QuickSwitchBar({
super.key, super.key,
@@ -15,6 +17,7 @@ class QuickSwitchBar extends StatelessWidget {
required this.onDestinationSelected, required this.onDestinationSelected,
this.contactsUnreadCount = 0, this.contactsUnreadCount = 0,
this.channelsUnreadCount = 0, this.channelsUnreadCount = 0,
this.highContrast = false,
}); });
@override @override
@@ -22,6 +25,14 @@ class QuickSwitchBar extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final labelStyle = theme.textTheme.labelMedium ?? const TextStyle(); 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( return SizedBox(
width: double.infinity, width: double.infinity,
@@ -31,9 +42,11 @@ class QuickSwitchBar extends StatelessWidget {
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.transparent, color: background,
border: Border.all( border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.4), color: highContrast
? MapPalette.border
: colorScheme.outlineVariant.withValues(alpha: 0.4),
), ),
), ),
child: NavigationBarTheme( child: NavigationBarTheme(
@@ -41,22 +54,18 @@ class QuickSwitchBar extends StatelessWidget {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
indicatorColor: colorScheme.primaryContainer, indicatorColor: indicator,
labelTextStyle: WidgetStateProperty.resolveWith((states) { labelTextStyle: WidgetStateProperty.resolveWith((states) {
final isSelected = states.contains(WidgetState.selected); final isSelected = states.contains(WidgetState.selected);
return labelStyle.copyWith( return labelStyle.copyWith(
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected color: isSelected ? selectedColor : unselectedColor,
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
); );
}), }),
iconTheme: WidgetStateProperty.resolveWith((states) { iconTheme: WidgetStateProperty.resolveWith((states) {
final isSelected = states.contains(WidgetState.selected); final isSelected = states.contains(WidgetState.selected);
return IconThemeData( return IconThemeData(
color: isSelected color: isSelected ? selectedColor : unselectedColor,
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
); );
}), }),
), ),
+8 -5
View File
@@ -7,6 +7,9 @@ import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/screens/companion_radio_stats_screen.dart'; import 'package:meshcore_open/screens/companion_radio_stats_screen.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../theme/mesh_theme.dart';
import 'mesh_ui.dart';
void pushCompanionRadioStatsScreen(BuildContext context) { void pushCompanionRadioStatsScreen(BuildContext context) {
Navigator.push( Navigator.push(
context, context,
@@ -140,12 +143,12 @@ class AirActivityDotState extends State<AirActivityDot> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final on = widget.active && _blink; final on = widget.active && _blink;
return Icon( final scheme = Theme.of(context).colorScheme;
Icons.circle, return PulseDot(
size: 12, color: on ? MeshPalette.blue : scheme.outline,
color: on ? scheme.primary : scheme.outline, size: 11,
animate: false,
); );
} }
} }
+30 -15
View File
@@ -9,6 +9,8 @@ import '../l10n/contact_localization.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import 'routing_sheet.dart'; import 'routing_sheet.dart';
@@ -269,26 +271,40 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector); final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1; final isFloodMode = repeater.pathOverride == -1;
return AlertDialog( return AlertDialog(
title: Row( title: Row(
children: [ children: [
Icon(Icons.cell_tower, color: Theme.of(context).colorScheme.tertiary), AvatarCircle(
const SizedBox(width: 8), name: repeater.name,
size: 40,
color: MeshPalette.warn,
icon: Icons.cell_tower,
),
const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(l10n.login_repeaterLogin), Text(
l10n.login_repeaterLogin,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
Text( Text(
repeater.name, repeater.name,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.normal, 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<RepeaterLoginDialog> {
Icon( Icon(
Icons.error, Icons.error,
size: 18, size: 18,
color: Theme.of(context).colorScheme.error, color: scheme.error,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
_loginError!, _loginError!,
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.error, color: scheme.error,
fontSize: 13, fontSize: 13,
), ),
), ),
@@ -341,7 +357,6 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: l10n.login_password, labelText: l10n.login_password,
hintText: l10n.login_enterPassword, hintText: l10n.login_enterPassword,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock), prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
@@ -390,9 +405,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
children: [ children: [
Text( Text(
l10n.login_routing, l10n.login_routing,
style: const TextStyle( style: MeshTheme.accentLabel(
fontSize: 12, color: scheme.onSurfaceVariant,
fontWeight: FontWeight.bold, fontSize: 11,
), ),
), ),
const Spacer(), const Spacer(),
@@ -421,7 +436,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
Icons.auto_mode, Icons.auto_mode,
size: 20, size: 20,
color: !isFloodMode color: !isFloodMode
? Theme.of(context).primaryColor ? scheme.primary
: null, : null,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -444,7 +459,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
Icons.waves, Icons.waves,
size: 20, size: 20,
color: isFloodMode color: isFloodMode
? Theme.of(context).primaryColor ? scheme.primary
: null, : null,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -468,7 +483,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
repeater.pathLabel(context.l10n), repeater.pathLabel(context.l10n),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -502,7 +517,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
height: 16, height: 16,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary, color: scheme.onPrimary,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
+28 -13
View File
@@ -10,6 +10,8 @@ import '../l10n/contact_localization.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../helpers/snack_bar_builder.dart'; import '../helpers/snack_bar_builder.dart';
import 'routing_sheet.dart'; import 'routing_sheet.dart';
@@ -226,26 +228,40 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector); final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1; final isFloodMode = repeater.pathOverride == -1;
return AlertDialog( return AlertDialog(
title: Row( title: Row(
children: [ children: [
Icon(Icons.group, color: Theme.of(context).colorScheme.secondary), AvatarCircle(
const SizedBox(width: 8), name: repeater.name,
size: 40,
color: MeshPalette.magenta,
icon: Icons.group,
),
const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(l10n.login_roomLogin), Text(
l10n.login_roomLogin,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
Text( Text(
repeater.name, repeater.name,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.normal, 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<RoomLoginDialog> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: l10n.login_password, labelText: l10n.login_password,
hintText: l10n.login_enterPassword, hintText: l10n.login_enterPassword,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock), prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
@@ -319,9 +334,9 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
children: [ children: [
Text( Text(
l10n.login_routing, l10n.login_routing,
style: const TextStyle( style: MeshTheme.accentLabel(
fontSize: 12, color: scheme.onSurfaceVariant,
fontWeight: FontWeight.bold, fontSize: 11,
), ),
), ),
const Spacer(), const Spacer(),
@@ -350,7 +365,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
Icons.auto_mode, Icons.auto_mode,
size: 20, size: 20,
color: !isFloodMode color: !isFloodMode
? Theme.of(context).primaryColor ? scheme.primary
: null, : null,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -373,7 +388,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
Icons.waves, Icons.waves,
size: 20, size: 20,
color: isFloodMode color: isFloodMode
? Theme.of(context).primaryColor ? scheme.primary
: null, : null,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -397,7 +412,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
repeater.pathLabel(context.l10n), repeater.pathLabel(context.l10n),
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -431,7 +446,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
height: 16, height: 16,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary, color: scheme.onPrimary,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
+6 -5
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/mesh_theme.dart';
class SignalUi { class SignalUi {
final IconData icon; final IconData icon;
@@ -12,27 +13,27 @@ SignalUi signalUiForStrengthTier(int tier) {
case 0: case 0:
return const SignalUi( return const SignalUi(
icon: Icons.signal_cellular_4_bar, icon: Icons.signal_cellular_4_bar,
color: Colors.green, color: MeshPalette.signal,
); );
case 1: case 1:
return const SignalUi( return const SignalUi(
icon: Icons.signal_cellular_alt, icon: Icons.signal_cellular_alt,
color: Colors.lightGreen, color: MeshPalette.signalDim,
); );
case 2: case 2:
return const SignalUi( return const SignalUi(
icon: Icons.signal_cellular_alt_2_bar, icon: Icons.signal_cellular_alt_2_bar,
color: Colors.amber, color: MeshPalette.warn,
); );
case 3: case 3:
return const SignalUi( return const SignalUi(
icon: Icons.signal_cellular_alt_1_bar, icon: Icons.signal_cellular_alt_1_bar,
color: Colors.orange, color: MeshPalette.warnDim,
); );
default: default:
return const SignalUi( return const SignalUi(
icon: Icons.signal_cellular_alt_1_bar, icon: Icons.signal_cellular_alt_1_bar,
color: Colors.red, color: MeshPalette.alert,
); );
} }
} }
+40 -17
View File
@@ -5,6 +5,8 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../theme/mesh_theme.dart';
import 'mesh_ui.dart';
import 'signal_ui.dart'; import 'signal_ui.dart';
Contact? _getRepeaterPrefixMatchNearLocation( Contact? _getRepeaterPrefixMatchNearLocation(
@@ -218,10 +220,6 @@ class _SNRIndicatorState extends State<SNRIndicator> {
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final repeater = directBestRepeaters[index]; final repeater = directBestRepeaters[index];
final snrUi = snrUiFromSNR(
repeater.snr,
widget.connector.currentSf,
);
final allContacts = widget.connector.allContacts; final allContacts = widget.connector.allContacts;
final selfLat = widget.connector.selfLatitude; final selfLat = widget.connector.selfLatitude;
@@ -242,22 +240,47 @@ class _SNRIndicatorState extends State<SNRIndicator> {
); );
final name = contact?.name; final name = contact?.name;
final hex = repeater.pubkeyFirstByte
.toRadixString(16)
.padLeft(2, '0');
final snrColor = MeshTheme.snrColor(
repeater.snr,
blocked: false,
);
return Column( return Padding(
children: [ padding: const EdgeInsets.symmetric(
ListTile( horizontal: 16,
leading: Icon(snrUi.icon, color: snrUi.color), vertical: 10,
title: Text( ),
name ?? child: Row(
repeater.pubkeyFirstByte children: [
.toRadixString(16) AvatarCircle(
.padLeft(2, '0'), name: name ?? hex,
size: 36,
color: snrColor,
), ),
subtitle: Text( const SizedBox(width: 12),
'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}', 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,
),
),
],
),
), ),
), ],
], ),
); );
}, },
), ),
+2 -7
View File
@@ -11,6 +11,7 @@ import '../models/app_settings.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import 'themed_map_tile_layer.dart';
class TelemetryLocationMap extends StatefulWidget { class TelemetryLocationMap extends StatefulWidget {
final double latitude; final double latitude;
@@ -114,13 +115,7 @@ class _TelemetryLocationMapState extends State<TelemetryLocationMap> {
), ),
), ),
children: [ children: [
TileLayer( ThemedMapTileLayer(tileCache: tileCache),
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
MarkerLayer( MarkerLayer(
markers: [ markers: [
...contacts.map(_buildContactMarker), ...contacts.map(_buildContactMarker),
+60
View File
@@ -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;
}
}
+9 -6
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/mesh_theme.dart';
class UnreadBadge extends StatelessWidget { class UnreadBadge extends StatelessWidget {
final int count; final int count;
@@ -9,17 +11,18 @@ class UnreadBadge extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final display = count > 9999 ? '9999+' : count.toString(); final display = count > 9999 ? '9999+' : count.toString();
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.redAccent, color: MeshPalette.blue.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(MeshRadii.pill),
border: Border.all(color: MeshPalette.blue.withValues(alpha: 0.45)),
), ),
child: Text( child: Text(
display, display,
style: const TextStyle( style: MeshTheme.mono(
color: Colors.white,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
color: MeshPalette.blue,
), ),
), ),
); );
+28 -9
View File
@@ -1,30 +1,49 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
class UnreadDivider extends StatelessWidget { class UnreadDivider extends StatelessWidget {
const UnreadDivider({super.key}); const UnreadDivider({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary; final scheme = Theme.of(context).colorScheme;
final color = scheme.primary;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 10),
child: Row( child: Row(
children: [ children: [
Expanded(child: Divider(color: color)), Expanded(
Padding( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8), 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( child: Text(
context.l10n.chat_newMessages, context.l10n.chat_newMessages,
style: TextStyle( style: MeshTheme.mono(
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: color, 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),
),
),
], ],
), ),
); );
-1
View File
@@ -8,7 +8,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flserial flserial
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)
+698 -1
View File
@@ -1 +1,698 @@
{} {
"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"
]
}
-1
View File
@@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flserial flserial
flutter_local_notifications_windows flutter_local_notifications_windows
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)