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,
Clip? clipBehavior,
}) {
final messenger = ScaffoldMessenger.of(context);
// Callers often reach here after an async gap; the context may already be
// unmounted, or deactivated (popped but not yet disposed) — ancestor
// lookups on a deactivated element throw. Showing nothing is the right
// outcome in both cases.
if (!context.mounted) return;
var isActive = true;
assert(() {
isActive = (context as Element).debugIsActive;
return true;
}());
if (!isActive) return;
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return;
messenger.showSnackBar(
SnackBar(
key: key,
+94 -1
View File
@@ -900,6 +900,17 @@
},
"chat_invalidLink": "Invalid link format",
"map_title": "Node Map",
"map_searchHint": "Search node name or ID",
"map_activity": "Activity",
"map_online": "Online",
"map_recent": "Recent",
"map_stale": "Stale",
"map_visible": "Visible",
"map_hidden": "Hidden",
"map_centerOnNode": "Center on node",
"map_details": "Details",
"map_noGps": "No GPS",
"map_noResults": "No matching nodes",
"map_lineOfSight": "Line of Sight",
"map_losScreenTitle": "Line of Sight",
"map_noNodesWithLocation": "No nodes with location data",
@@ -2501,5 +2512,87 @@
}
},
"pathTrace_legendGpsConfirmed": "GPS confirmed",
"pathTrace_legendInferred": "Inferred position"
"pathTrace_legendInferred": "Inferred position",
"pathMap_viewSingle": "Single",
"pathMap_viewCombined": "Combined",
"pathMap_play": "Play",
"pathMap_pause": "Pause",
"pathMap_replay": "Replay",
"pathMap_stepBack": "Previous hop",
"pathMap_stepForward": "Next hop",
"pathMap_animationOn": "Show packet animation",
"pathMap_animationOff": "Hide packet animation",
"pathMap_hopOf": "Hop {current} of {total}",
"@pathMap_hopOf": {
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"pathMap_observedPaths": "Observed paths: {count}",
"@pathMap_observedPaths": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"pathMap_primary": "Primary",
"pathMap_alternate": "Alt {index}",
"@pathMap_alternate": {
"placeholders": {
"index": {
"type": "int"
}
}
},
"pathMap_hopCount": "{count, plural, =1{1 hop} other{{count} hops}}",
"@pathMap_hopCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"pathMap_gpsCount": "{confirmed}/{total} GPS",
"@pathMap_gpsCount": {
"placeholders": {
"confirmed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"pathMap_legendShared": "Shared segment",
"pathMap_legendEstimated": "Estimated segment",
"pathMap_sharedNodeCount": "Used by {count} paths",
"@pathMap_sharedNodeCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"pathMap_partialAnimation": "{count, plural, =1{1 hop has no location — the shown path is partial} other{{count} hops have no location — the shown path is partial}}",
"@pathMap_partialAnimation": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"pathMap_showAllPaths": "Show all",
"pathMap_hidePath": "Hide path",
"pathMap_showPath": "Show path",
"pathMap_collapsePanel": "Collapse panel",
"pathMap_expandPanel": "Expand panel",
"pathMap_noLocation": "No location",
"pathMap_followPacket": "Lock view to packet",
"pathMap_unfollowPacket": "Unlock view from packet"
}
+228
View File
@@ -3154,6 +3154,72 @@ abstract class AppLocalizations {
/// **'Node Map'**
String get map_title;
/// No description provided for @map_searchHint.
///
/// In en, this message translates to:
/// **'Search node name or ID'**
String get map_searchHint;
/// No description provided for @map_activity.
///
/// In en, this message translates to:
/// **'Activity'**
String get map_activity;
/// No description provided for @map_online.
///
/// In en, this message translates to:
/// **'Online'**
String get map_online;
/// No description provided for @map_recent.
///
/// In en, this message translates to:
/// **'Recent'**
String get map_recent;
/// No description provided for @map_stale.
///
/// In en, this message translates to:
/// **'Stale'**
String get map_stale;
/// No description provided for @map_visible.
///
/// In en, this message translates to:
/// **'Visible'**
String get map_visible;
/// No description provided for @map_hidden.
///
/// In en, this message translates to:
/// **'Hidden'**
String get map_hidden;
/// No description provided for @map_centerOnNode.
///
/// In en, this message translates to:
/// **'Center on node'**
String get map_centerOnNode;
/// No description provided for @map_details.
///
/// In en, this message translates to:
/// **'Details'**
String get map_details;
/// No description provided for @map_noGps.
///
/// In en, this message translates to:
/// **'No GPS'**
String get map_noGps;
/// No description provided for @map_noResults.
///
/// In en, this message translates to:
/// **'No matching nodes'**
String get map_noResults;
/// No description provided for @map_lineOfSight.
///
/// In en, this message translates to:
@@ -7701,6 +7767,168 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Inferred position'**
String get pathTrace_legendInferred;
/// No description provided for @pathMap_viewSingle.
///
/// In en, this message translates to:
/// **'Single'**
String get pathMap_viewSingle;
/// No description provided for @pathMap_viewCombined.
///
/// In en, this message translates to:
/// **'Combined'**
String get pathMap_viewCombined;
/// No description provided for @pathMap_play.
///
/// In en, this message translates to:
/// **'Play'**
String get pathMap_play;
/// No description provided for @pathMap_pause.
///
/// In en, this message translates to:
/// **'Pause'**
String get pathMap_pause;
/// No description provided for @pathMap_replay.
///
/// In en, this message translates to:
/// **'Replay'**
String get pathMap_replay;
/// No description provided for @pathMap_stepBack.
///
/// In en, this message translates to:
/// **'Previous hop'**
String get pathMap_stepBack;
/// No description provided for @pathMap_stepForward.
///
/// In en, this message translates to:
/// **'Next hop'**
String get pathMap_stepForward;
/// No description provided for @pathMap_animationOn.
///
/// In en, this message translates to:
/// **'Show packet animation'**
String get pathMap_animationOn;
/// No description provided for @pathMap_animationOff.
///
/// In en, this message translates to:
/// **'Hide packet animation'**
String get pathMap_animationOff;
/// No description provided for @pathMap_hopOf.
///
/// In en, this message translates to:
/// **'Hop {current} of {total}'**
String pathMap_hopOf(int current, int total);
/// No description provided for @pathMap_observedPaths.
///
/// In en, this message translates to:
/// **'Observed paths: {count}'**
String pathMap_observedPaths(int count);
/// No description provided for @pathMap_primary.
///
/// In en, this message translates to:
/// **'Primary'**
String get pathMap_primary;
/// No description provided for @pathMap_alternate.
///
/// In en, this message translates to:
/// **'Alt {index}'**
String pathMap_alternate(int index);
/// No description provided for @pathMap_hopCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 hop} other{{count} hops}}'**
String pathMap_hopCount(int count);
/// No description provided for @pathMap_gpsCount.
///
/// In en, this message translates to:
/// **'{confirmed}/{total} GPS'**
String pathMap_gpsCount(int confirmed, int total);
/// No description provided for @pathMap_legendShared.
///
/// In en, this message translates to:
/// **'Shared segment'**
String get pathMap_legendShared;
/// No description provided for @pathMap_legendEstimated.
///
/// In en, this message translates to:
/// **'Estimated segment'**
String get pathMap_legendEstimated;
/// No description provided for @pathMap_sharedNodeCount.
///
/// In en, this message translates to:
/// **'Used by {count} paths'**
String pathMap_sharedNodeCount(int count);
/// No description provided for @pathMap_partialAnimation.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 hop has no location — the shown path is partial} other{{count} hops have no location — the shown path is partial}}'**
String pathMap_partialAnimation(int count);
/// No description provided for @pathMap_showAllPaths.
///
/// In en, this message translates to:
/// **'Show all'**
String get pathMap_showAllPaths;
/// No description provided for @pathMap_hidePath.
///
/// In en, this message translates to:
/// **'Hide path'**
String get pathMap_hidePath;
/// No description provided for @pathMap_showPath.
///
/// In en, this message translates to:
/// **'Show path'**
String get pathMap_showPath;
/// No description provided for @pathMap_collapsePanel.
///
/// In en, this message translates to:
/// **'Collapse panel'**
String get pathMap_collapsePanel;
/// No description provided for @pathMap_expandPanel.
///
/// In en, this message translates to:
/// **'Expand panel'**
String get pathMap_expandPanel;
/// No description provided for @pathMap_noLocation.
///
/// In en, this message translates to:
/// **'No location'**
String get pathMap_noLocation;
/// No description provided for @pathMap_followPacket.
///
/// In en, this message translates to:
/// **'Lock view to packet'**
String get pathMap_followPacket;
/// No description provided for @pathMap_unfollowPacket.
///
/// In en, this message translates to:
/// **'Unlock view from packet'**
String get pathMap_unfollowPacket;
}
class _AppLocalizationsDelegate
+140
View File
@@ -1738,6 +1738,39 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get map_title => 'Карта на възлите';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Линия на видимост';
@@ -4479,4 +4512,111 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Извлечена позиция';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1737,6 +1737,39 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get map_title => 'Karte';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Sichtlinie';
@@ -4500,4 +4533,111 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Abgeleitete Position';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1703,6 +1703,39 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get map_title => 'Node Map';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Line of Sight';
@@ -4405,4 +4438,111 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Inferred position';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1734,6 +1734,39 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get map_title => 'Mapa de Nodos';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Línea de visión';
@@ -4486,4 +4519,111 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Posición inferida';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1741,6 +1741,39 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get map_title => 'Carte des nœuds';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Ligne de vue';
@@ -4512,4 +4545,111 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Position déduite';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1744,6 +1744,39 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get map_title => 'Grafikus ábrázás';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Látási vonal';
@@ -4500,4 +4533,111 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Feltehető helyzet';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1738,6 +1738,39 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get map_title => 'Mappa Nodi';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Linea di vista';
@@ -4492,4 +4525,111 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Posizione dedotta';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1661,6 +1661,39 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get map_title => 'ノードマップ';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => '視界';
@@ -4257,4 +4290,111 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get pathTrace_legendInferred => '推測される位置';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1656,6 +1656,39 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get map_title => '노드 매핑';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => '시야';
@@ -4254,4 +4287,111 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get pathTrace_legendInferred => '추론된 위치';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1721,6 +1721,39 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get map_title => 'Kaart van de knopen';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Zichtlijn';
@@ -4463,4 +4496,111 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Afgeleide positie';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1750,6 +1750,39 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get map_title => 'Mapa węzłów';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Linia wzroku';
@@ -4501,4 +4534,111 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Wywnioskowana pozycja';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1734,6 +1734,39 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get map_title => 'Mapa de Nós';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Linha de visão';
@@ -4480,4 +4513,111 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Posição inferida';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1739,6 +1739,39 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get map_title => 'Карта нод';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Линия видимости';
@@ -4496,4 +4529,111 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Выведенная позиция';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1726,6 +1726,39 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get map_title => 'Mapa uzlov';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Úroveň výhľadu';
@@ -4462,4 +4495,111 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Odvodená poloha';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1719,6 +1719,39 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get map_title => 'Mapa omrežja';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Linija vida';
@@ -4461,4 +4494,111 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Izpeljana lokacija';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1712,6 +1712,39 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get map_title => 'Nodkarta';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Synlinje';
@@ -4435,4 +4468,111 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Antagen position';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1732,6 +1732,39 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get map_title => 'Карта вузлів';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => 'Пряма видимість';
@@ -4495,4 +4528,111 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get pathTrace_legendInferred => 'Висновок щодо положення';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+140
View File
@@ -1634,6 +1634,39 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_title => '节点地图';
@override
String get map_searchHint => 'Search node name or ID';
@override
String get map_activity => 'Activity';
@override
String get map_online => 'Online';
@override
String get map_recent => 'Recent';
@override
String get map_stale => 'Stale';
@override
String get map_visible => 'Visible';
@override
String get map_hidden => 'Hidden';
@override
String get map_centerOnNode => 'Center on node';
@override
String get map_details => 'Details';
@override
String get map_noGps => 'No GPS';
@override
String get map_noResults => 'No matching nodes';
@override
String get map_lineOfSight => '视线';
@@ -4145,4 +4178,111 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get pathTrace_legendInferred => '推测的位置';
@override
String get pathMap_viewSingle => 'Single';
@override
String get pathMap_viewCombined => 'Combined';
@override
String get pathMap_play => 'Play';
@override
String get pathMap_pause => 'Pause';
@override
String get pathMap_replay => 'Replay';
@override
String get pathMap_stepBack => 'Previous hop';
@override
String get pathMap_stepForward => 'Next hop';
@override
String get pathMap_animationOn => 'Show packet animation';
@override
String get pathMap_animationOff => 'Hide packet animation';
@override
String pathMap_hopOf(int current, int total) {
return 'Hop $current of $total';
}
@override
String pathMap_observedPaths(int count) {
return 'Observed paths: $count';
}
@override
String get pathMap_primary => 'Primary';
@override
String pathMap_alternate(int index) {
return 'Alt $index';
}
@override
String pathMap_hopCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops',
one: '1 hop',
);
return '$_temp0';
}
@override
String pathMap_gpsCount(int confirmed, int total) {
return '$confirmed/$total GPS';
}
@override
String get pathMap_legendShared => 'Shared segment';
@override
String get pathMap_legendEstimated => 'Estimated segment';
@override
String pathMap_sharedNodeCount(int count) {
return 'Used by $count paths';
}
@override
String pathMap_partialAnimation(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hops have no location — the shown path is partial',
one: '1 hop has no location — the shown path is partial',
);
return '$_temp0';
}
@override
String get pathMap_showAllPaths => 'Show all';
@override
String get pathMap_hidePath => 'Hide path';
@override
String get pathMap_showPath => 'Show path';
@override
String get pathMap_collapsePanel => 'Collapse panel';
@override
String get pathMap_expandPanel => 'Expand panel';
@override
String get pathMap_noLocation => 'No location';
@override
String get pathMap_followPacket => 'Lock view to packet';
@override
String get pathMap_unfollowPacket => 'Unlock view from packet';
}
+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 '../services/app_debug_log_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
@@ -58,27 +59,57 @@ class AppDebugLogScreen extends StatelessWidget {
child: hasEntries
? ListView.separated(
itemCount: entries.length,
separatorBuilder: (_, _) => const Divider(height: 1),
separatorBuilder: (_, _) =>
const Divider(height: 1, color: MeshPalette.line),
itemBuilder: (context, index) {
final entry = entries[index];
return ListTile(
dense: true,
leading: _buildLevelIcon(context, entry.level),
title: Text(
'[${entry.tag}] ${entry.message}',
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
),
return Container(
color: MeshPalette.bg,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
subtitle: Text(
entry.formattedTime,
style: TextStyle(
fontSize: 10,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLevelIcon(context, entry.level),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(
text: '[${entry.tag}] ',
style: MeshTheme.mono(
fontSize: 11.5,
color: _levelColor(entry.level),
),
),
TextSpan(
text: entry.message,
style: MeshTheme.mono(
fontSize: 11.5,
color: MeshPalette.ink2,
),
),
],
),
),
const SizedBox(height: 2),
Text(
entry.formattedTime,
style: MeshTheme.mono(
fontSize: 9.5,
color: MeshPalette.ink4,
),
),
],
),
),
],
),
);
},
@@ -87,29 +118,25 @@ class AppDebugLogScreen extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
const Icon(
Icons.bug_report_outlined,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
color: MeshPalette.ink3,
),
const SizedBox(height: 16),
Text(
context.l10n.debugLog_noEntries,
style: TextStyle(
style: const TextStyle(
fontSize: 16,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
color: MeshPalette.ink3,
),
),
const SizedBox(height: 8),
Text(
context.l10n.debugLog_enableInSettings,
style: TextStyle(
style: const TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
color: MeshPalette.ink3,
),
),
],
@@ -121,19 +148,37 @@ class AppDebugLogScreen extends StatelessWidget {
);
}
Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) {
final colorScheme = Theme.of(context).colorScheme;
Color _levelColor(AppDebugLogLevel level) {
switch (level) {
case AppDebugLogLevel.info:
return Icon(Icons.info_outline, size: 18, color: colorScheme.primary);
return MeshPalette.blue;
case AppDebugLogLevel.warning:
return Icon(
return MeshPalette.warn;
case AppDebugLogLevel.error:
return MeshPalette.alert;
}
}
Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) {
switch (level) {
case AppDebugLogLevel.info:
return const Icon(
Icons.info_outline,
size: 18,
color: MeshPalette.blue,
);
case AppDebugLogLevel.warning:
return const Icon(
Icons.warning_amber_outlined,
size: 18,
color: colorScheme.tertiary,
color: MeshPalette.warn,
);
case AppDebugLogLevel.error:
return Icon(Icons.error_outline, size: 18, color: colorScheme.error);
return const Icon(
Icons.error_outline,
size: 18,
color: MeshPalette.alert,
);
}
}
}
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 '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
@@ -32,6 +33,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
return Scaffold(
appBar: AppBar(
title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle),
centerTitle: true,
actions: [
IconButton(
tooltip: context.l10n.debugLog_copyLog,
@@ -101,23 +103,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
itemCount: showingFrames
? entries.length
: rawEntries.length,
separatorBuilder: (_, _) => const Divider(height: 1),
separatorBuilder: (_, _) =>
const Divider(height: 1, color: MeshPalette.line),
itemBuilder: (context, index) {
if (showingFrames) {
final entry = entries[index];
final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile(
dense: true,
title: Text(entry.description),
subtitle: Text('${entry.hexPreview}\n$time'),
isThreeLine: true,
leading: Icon(
entry.outgoing
? Icons.upload
: Icons.download,
size: 18,
),
return GestureDetector(
onLongPress: () async {
await Clipboard.setData(
ClipboardData(
@@ -131,6 +124,60 @@ class _BleDebugLogScreenState extends State<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 time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile(
dense: true,
title: Text(info.title),
subtitle: Text('${info.summary}\n$time'),
isThreeLine: true,
leading: const Icon(Icons.download, size: 18),
return GestureDetector(
onTap: () => _showRawDialog(context, info),
child: Container(
color: MeshPalette.bg,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.download,
size: 18,
color: MeshPalette.signal,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
info.title,
style: MeshTheme.mono(
fontSize: 11.5,
color: MeshPalette.ink,
),
),
const SizedBox(height: 2),
Text(
info.summary,
style: MeshTheme.mono(
fontSize: 10,
color: MeshPalette.ink3,
),
),
const SizedBox(height: 2),
Text(
time,
style: MeshTheme.mono(
fontSize: 9.5,
color: MeshPalette.ink4,
),
),
],
),
),
],
),
),
);
},
)
: Center(
child: Text(context.l10n.debugLog_noBleActivity),
child: Text(
context.l10n.debugLog_noBleActivity,
style: const TextStyle(color: MeshPalette.ink3),
),
),
),
],
+297 -224
View File
@@ -25,7 +25,6 @@ import '../models/translation_support.dart';
import '../services/app_settings_service.dart';
import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart';
import '../helpers/contact_ui.dart';
import '../widgets/byte_count_input.dart';
import '../widgets/empty_state.dart';
import '../widgets/chat_zoom_wrapper.dart';
@@ -39,6 +38,8 @@ import '../widgets/radio_stats_entry.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/translated_message_content.dart';
import '../widgets/unread_divider.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
@@ -108,7 +109,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
totalMessages: messages.length,
onJumped: () {
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];
if (key == null) {
// The auto unread-jump can resolve a frame after navigating away;
// a deactivated context can't host a snackbar.
if (quiet || !mounted || !context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(context.l10n.chat_originalMessageNotFound),
@@ -491,6 +495,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
final scheme = Theme.of(context).colorScheme;
final gifId = GifHelper.parseGif(message.text);
final poi = parseMarkerText(message.text);
final translatedDisplayText =
@@ -507,9 +512,34 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
? message.pathVariants.first
: Uint8List(0));
// Bubble colors outgoing uses MeshPalette.me / meBorder / meInk.
final bubbleColor = isOutgoing
? MeshPalette.me
: scheme.surfaceContainerLow;
final bubbleBorder = isOutgoing
? MeshPalette.meBorder
: scheme.outlineVariant;
final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.65);
const bodyFontSize = 14.0;
// Asymmetric radius matching chat_screen bubbles.
final borderRadius = isOutgoing
? const BorderRadius.only(
topLeft: Radius.circular(MeshRadii.lg),
topRight: Radius.circular(MeshRadii.lg),
bottomLeft: Radius.circular(MeshRadii.lg),
bottomRight: Radius.circular(MeshRadii.xs),
)
: const BorderRadius.only(
topLeft: Radius.circular(MeshRadii.xs),
topRight: Radius.circular(MeshRadii.lg),
bottomLeft: Radius.circular(MeshRadii.lg),
bottomRight: Radius.circular(MeshRadii.lg),
);
const maxSwipeOffset = 64.0;
const replySwipeThreshold = 64.0;
const bodyFontSize = 14.0;
final messageBody = LayoutBuilder(
builder: (context, constraints) => Column(
crossAxisAlignment: isOutgoing
@@ -520,11 +550,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!isOutgoing) ...[
_buildAvatar(message.senderName),
const SizedBox(width: 8),
const SizedBox(width: 6),
],
Flexible(
child: GestureDetector(
@@ -540,15 +570,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
vertical: 8,
),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.65,
maxWidth: constraints.maxWidth * 0.72,
),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
color: bubbleColor,
borderRadius: borderRadius,
border: Border.all(color: bubbleBorder, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -564,14 +591,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
: EdgeInsets.zero,
child: Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w700,
color: _colorForName(message.senderName),
),
),
),
if (gifId == null) const SizedBox(height: 4),
if (gifId == null) const SizedBox(height: 2),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message, textScale),
@@ -594,13 +621,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing
? Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.6),
fallbackTextColor: textColor.withValues(
alpha: 0.7,
),
),
),
],
@@ -615,40 +638,48 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
displayText: translatedDisplayText,
originalText: originalDisplayText,
style: TextStyle(
color: textColor,
fontSize: bodyFontSize * textScale,
),
originalStyle: TextStyle(
fontSize: bodyFontSize * textScale,
fontStyle: FontStyle.italic,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.72),
color: textColor.withValues(alpha: 0.72),
),
),
),
],
),
if (enableTracing && displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
const SizedBox(height: 3),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
context.l10n.channels_via(
_formatPathPrefixes(displayPath),
),
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
RouteChip(
isDirect: (message.pathLength ?? -1) >= 0,
hops: (message.pathLength ?? -1) >= 0
? message.pathLength
: null,
),
const SizedBox(width: 4),
Text(
context.l10n.channels_via(
_formatPathPrefixes(displayPath),
),
style: MeshTheme.mono(
fontSize: 9.5 * textScale,
color: metaColor,
),
),
],
),
),
],
const SizedBox(height: 4),
const SizedBox(height: 3),
Padding(
padding: gifId != null
? const EdgeInsets.only(
@@ -662,30 +693,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
_formatTime(context, message.timestamp),
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
style: MeshTheme.mono(
fontSize: 10 * textScale,
color: metaColor,
),
),
if (enableTracing && message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(
Icons.repeat,
size: 12 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
size: 11 * textScale,
color: metaColor,
),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
style: MeshTheme.mono(
fontSize: 10 * textScale,
color: metaColor,
),
),
],
@@ -705,6 +730,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
isFailed:
message.status ==
ChannelMessageStatus.failed,
onColor: metaColor,
),
],
],
@@ -720,7 +746,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
child: _buildReactionsDisplay(message),
),
],
@@ -739,7 +765,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
} else {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: 3),
child: messageBody,
);
}
@@ -836,7 +862,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(MeshRadii.sm),
border: Border(
left: BorderSide(color: colorScheme.primary, width: 3),
),
@@ -849,9 +875,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
style: TextStyle(
fontSize: 11 * textScale,
fontWeight: FontWeight.bold,
color: isOwnNode
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
color: isOwnNode ? colorScheme.primary : colorScheme.onSurface,
),
),
const SizedBox(height: 2),
@@ -863,6 +887,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
Widget _buildReactionsDisplay(ChannelMessage message) {
final scheme = Theme.of(context).colorScheme;
return Wrap(
spacing: 6,
runSpacing: 6,
@@ -873,27 +898,29 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(MeshRadii.pill),
border: Border.all(color: scheme.outlineVariant, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
Text(
emoji,
style: MeshTheme.emoji(fontSize: 16),
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSecondaryContainer,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
),
],
@@ -912,20 +939,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
String senderName, {
Widget? trailing,
}) {
final colorScheme = Theme.of(context).colorScheme;
final textColor = isOutgoing
? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final scheme = Theme.of(context).colorScheme;
final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.7);
final channelColor = widget.channel.isPublicChannel
? Colors.orange
: Colors.blue;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.location_on_outlined, color: channelColor),
icon: Icon(Icons.location_on_outlined, color: scheme.primary),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
onPressed: () {
@@ -991,41 +1013,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
Widget _buildAvatar(String senderName) {
final initial = firstCharacterOrEmoji(senderName);
final color = colorForName(senderName);
return CircleAvatar(
radius: 18,
backgroundColor: color.withValues(alpha: 0.2),
child: Text(
initial,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: color,
),
),
);
return AvatarCircle(name: senderName, size: 32);
}
Widget _buildReplyBanner(double textScale) {
final message = _replyingToMessage!;
final scheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
color: scheme.surfaceContainerHigh,
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
bottom: BorderSide(color: scheme.outlineVariant, width: 1),
),
),
child: Row(
children: [
Icon(
Icons.reply,
size: 18,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
Icon(Icons.reply, size: 18, color: scheme.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
@@ -1033,10 +1038,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
context.l10n.chat_replyingTo(message.senderName),
style: TextStyle(
fontSize: 12 * textScale,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSecondaryContainer,
style: MeshTheme.mono(
fontSize: 11 * textScale,
fontWeight: FontWeight.w700,
color: scheme.primary,
),
),
Text(
@@ -1045,9 +1050,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11 * textScale,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
color: scheme.onSurfaceVariant,
),
),
],
@@ -1056,7 +1059,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: _cancelReply,
color: Theme.of(context).colorScheme.onSecondaryContainer,
color: scheme.onSurfaceVariant,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
),
],
@@ -1068,6 +1071,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final connector = context.watch<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName);
final settings = context.watch<AppSettingsService>().settings;
final scheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -1081,123 +1085,166 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
},
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
color: scheme.surface,
border: Border(
top: BorderSide(color: scheme.outlineVariant, width: 1),
),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
if (settings.translationEnabled)
MessageTranslationButton(
enabled: settings.composerTranslationEnabled,
languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
fallbackTextColor: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
if (settings.translationEnabled)
MessageTranslationButton(
enabled: settings.composerTranslationEnabled,
languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey ==
LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
scheme.surfaceContainerHighest,
fallbackTextColor: scheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
),
],
),
);
}
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(),
encoder:
(connector.isChannelSmazEnabled(
widget.channel.index,
) ||
connector.isChannelCyr2LatEnabled(
widget.channel.index,
))
? (text) => connector.prepareChannelOutboundText(
widget.channel.index,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
MeshRadii.pill,
),
borderSide: BorderSide(
color: scheme.outlineVariant,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
MeshRadii.pill,
),
borderSide: BorderSide(
color: scheme.outlineVariant,
),
),
],
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
MeshRadii.pill,
),
borderSide: BorderSide(
color: scheme.primary,
width: 1.5,
),
),
filled: true,
fillColor: scheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
),
);
},
),
),
const SizedBox(width: 6),
ValueListenableBuilder<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);
showModalBottomSheet(
context: context,
showMeshSheet(
context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(
title: message.text.length > 40
? '${message.text.substring(0, 40)}'
: message.text,
subtitle: message.senderName.isNotEmpty
? message.senderName
: null,
),
ListTile(
leading: const Icon(Icons.reply),
title: Text(context.l10n.chat_reply),
@@ -1402,7 +1457,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_markAsUnread(message);
},
),
const Divider(),
const Divider(height: 1),
ListTile(
leading: Icon(
Icons.delete_outline,
@@ -1417,6 +1472,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
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())
.join(',');
}
/// Deterministic name-to-hue mapping consistent with [AvatarCircle].
Color _colorForName(String name) {
const hues = [
MeshPalette.blue,
MeshPalette.magenta,
MeshPalette.signal,
MeshPalette.warn,
Color(0xFF8FA8F0),
Color(0xFF6FD9CE),
];
var h = 0;
for (final c in name.codeUnits) {
h = (h * 31 + c) & 0x7fffffff;
}
return hues[h % hues.length];
}
}
class _SwipeReplyBubble extends StatefulWidget {
@@ -1640,7 +1713,7 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
onPointerUp: (event) => _handleSwipePointerUp(event.position),
onPointerCancel: (_) => _resetSwipe(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 8),
child: Stack(
alignment: Alignment.center,
children: [
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 'channel_message_path_screen.dart';
import 'map_screen.dart';
import '../helpers/contact_ui.dart';
import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart';
@@ -43,6 +42,8 @@ import '../widgets/translated_message_content.dart';
import '../l10n/l10n.dart';
import '../helpers/snack_bar_builder.dart';
import '../widgets/unread_divider.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'telemetry_screen.dart';
class ChatScreen extends StatefulWidget {
@@ -448,119 +449,157 @@ class _ChatScreenState extends State<ChatScreen> {
Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes();
final colorScheme = Theme.of(context).colorScheme;
final scheme = Theme.of(context).colorScheme;
final settings = context.watch<AppSettingsService>().settings;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
color: scheme.surface,
border: Border(top: BorderSide(color: scheme.outlineVariant, width: 1)),
),
child: SafeArea(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
if (settings.translationEnabled)
MessageTranslationButton(
enabled: settings.composerTranslationEnabled,
languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage(connector);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
colorScheme.surfaceContainerHighest,
fallbackTextColor: colorScheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
if (settings.translationEnabled)
MessageTranslationButton(
enabled: settings.composerTranslationEnabled,
languageCode: settings.translationTargetLanguageCode,
onPressed: _showTranslationOptions,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
(event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey ==
LogicalKeyboardKey.numpadEnter)) {
_sendMessage(connector);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
scheme.surfaceContainerHighest,
fallbackTextColor: scheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
),
],
),
);
}
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(connector),
encoder:
(connector.isContactSmazEnabled(
widget.contact.publicKeyHex,
) ||
connector.isContactCyr2LatEnabled(
widget.contact.publicKeyHex,
))
? (text) => connector.prepareContactOutboundText(
widget.contact,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: BorderSide(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: BorderSide(color: scheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
borderSide: BorderSide(
color: scheme.primary,
width: 1.5,
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_textController.clear();
_textFieldFocusNode.requestFocus();
},
),
],
),
filled: true,
fillColor: scheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
),
);
}
return ByteCountedTextField(
maxBytes: maxBytes,
controller: _textController,
focusNode: _textFieldFocusNode,
hintText: context.l10n.chat_typeMessage,
onSubmitted: (_) => _sendMessage(connector),
encoder:
(connector.isContactSmazEnabled(
widget.contact.publicKeyHex,
) ||
connector.isContactCyr2LatEnabled(
widget.contact.publicKeyHex,
))
? (text) => connector.prepareContactOutboundText(
widget.contact,
text,
)
: null,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
},
),
),
const SizedBox(width: 6),
ValueListenableBuilder<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_sendMessageTo(
_resolveContact(connector).name,
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 14,
style: IconButton.styleFrom(
backgroundColor: hasText
? scheme.primary
: scheme.surfaceContainerHighest,
foregroundColor: hasText
? scheme.onPrimary
: scheme.onSurfaceVariant,
minimumSize: const Size(40, 40),
shape: const CircleBorder(),
),
onPressed: hasText
? () {
HapticFeedback.lightImpact();
_sendMessage(connector);
}
: null,
),
);
},
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
tooltip: context.l10n.chat_sendMessageTo(
_resolveContact(connector).name,
),
onPressed: () => _sendMessage(connector),
),
],
],
),
),
),
);
@@ -1043,12 +1082,17 @@ class _ChatScreenState extends State<ChatScreen> {
) &&
(message.translatedText?.trim().isEmpty ?? true);
showModalBottomSheet(
context: context,
showMeshSheet(
context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(
title: message.text.length > 40
? '${message.text.substring(0, 40)}'
: message.text,
),
// Can't react to your own messages
if (!message.isOutgoing)
ListTile(
@@ -1118,7 +1162,7 @@ class _ChatScreenState extends State<ChatScreen> {
_openChat(context, contact);
},
),
const Divider(),
const Divider(height: 1),
ListTile(
leading: Icon(
Icons.delete_outline,
@@ -1133,6 +1177,7 @@ class _ChatScreenState extends State<ChatScreen> {
await _deleteMessage(message);
},
),
const SizedBox(height: 8),
],
),
),
@@ -1221,20 +1266,45 @@ class _MessageBubble extends StatelessWidget {
final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
final colorScheme = Theme.of(context).colorScheme;
final scheme = Theme.of(context).colorScheme;
final gifId = GifHelper.parseGif(message.text);
final poi = parseMarkerText(message.text);
final isFailed = message.status == MessageStatus.failed;
// Bubble colors outgoing uses MeshPalette.me / meBorder / meInk.
final bubbleColor = isFailed
? colorScheme.errorContainer
: (isOutgoing
? colorScheme.primary
: colorScheme.surfaceContainerHighest);
? scheme.errorContainer
: isOutgoing
? MeshPalette.me
: scheme.surfaceContainerLow;
final bubbleBorder = isFailed
? scheme.error
: isOutgoing
? MeshPalette.meBorder
: scheme.outlineVariant;
final textColor = isFailed
? colorScheme.onErrorContainer
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
final metaColor = textColor.withValues(alpha: 0.7);
? scheme.onErrorContainer
: isOutgoing
? MeshPalette.meInk
: scheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.65);
const bodyFontSize = 14.0;
// Asymmetric radius: outgoing top-left large, others also large; outgoing bottom-right tight.
final borderRadius = isOutgoing
? const BorderRadius.only(
topLeft: Radius.circular(MeshRadii.lg),
topRight: Radius.circular(MeshRadii.lg),
bottomLeft: Radius.circular(MeshRadii.lg),
bottomRight: Radius.circular(MeshRadii.xs),
)
: const BorderRadius.only(
topLeft: Radius.circular(MeshRadii.xs),
topRight: Radius.circular(MeshRadii.lg),
bottomLeft: Radius.circular(MeshRadii.lg),
bottomRight: Radius.circular(MeshRadii.lg),
);
// Do not strip room-server author bytes here: the parser stores them in
// fourByteRoomContactKey, so message.text is safe to render as-is.
final messageText = message.text;
@@ -1246,8 +1316,9 @@ class _MessageBubble extends StatelessWidget {
final originalDisplayText = isOutgoing
? message.originalText
: (translatedDisplayText != messageText ? messageText : null);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: 3),
child: Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
@@ -1263,11 +1334,11 @@ class _MessageBubble extends StatelessWidget {
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!isOutgoing) ...[
_buildAvatar(senderName, colorScheme),
const SizedBox(width: 8),
_buildAvatar(senderName),
const SizedBox(width: 6),
],
Flexible(
child: Container(
@@ -1278,14 +1349,12 @@ class _MessageBubble extends StatelessWidget {
vertical: 8,
),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.65,
maxWidth: constraints.maxWidth * 0.72,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.circular(16),
border: isFailed
? Border.all(color: colorScheme.error)
: null,
borderRadius: borderRadius,
border: Border.all(color: bubbleBorder, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1301,14 +1370,14 @@ class _MessageBubble extends StatelessWidget {
: EdgeInsets.zero,
child: Text(
senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w700,
color: _colorForName(senderName),
),
),
),
if (gifId == null) const SizedBox(height: 4),
if (gifId == null) const SizedBox(height: 2),
],
if (poi != null)
_buildPoiMessage(
@@ -1349,7 +1418,7 @@ class _MessageBubble extends StatelessWidget {
fontSize: bodyFontSize * textScale,
),
originalStyle: TextStyle(
color: textColor.withValues(alpha: 0.78),
color: textColor.withValues(alpha: 0.72),
fontSize: bodyFontSize * textScale,
),
),
@@ -1359,7 +1428,7 @@ class _MessageBubble extends StatelessWidget {
if (enableTracing &&
isOutgoing &&
message.retryCount > 0) ...[
const SizedBox(height: 4),
const SizedBox(height: 3),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
@@ -1372,15 +1441,15 @@ class _MessageBubble extends StatelessWidget {
.settings
.maxMessageRetries,
),
style: TextStyle(
fontSize: 10 * textScale,
style: MeshTheme.mono(
fontSize: 9.5 * textScale,
color: metaColor,
fontWeight: FontWeight.w500,
),
),
),
],
const SizedBox(height: 4),
const SizedBox(height: 3),
// Meta row: timestamp + status icon + optional tracing
Padding(
padding: gifId != null
? const EdgeInsets.only(
@@ -1395,13 +1464,13 @@ class _MessageBubble extends StatelessWidget {
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
style: MeshTheme.mono(
fontSize: 10 * textScale,
color: metaColor,
),
),
if (isOutgoing) ...[
const SizedBox(width: 4),
const SizedBox(width: 2),
MessageStatusIcon(
size: 12 * textScale,
onColor: metaColor,
@@ -1418,25 +1487,21 @@ class _MessageBubble extends StatelessWidget {
message.tripTimeMs != null &&
message.status ==
MessageStatus.delivered) ...[
const SizedBox(width: 4),
const SizedBox(width: 2),
Icon(
Icons.speed,
size: 10 * textScale,
color: isOutgoing
? metaColor
: Theme.of(
context,
).colorScheme.tertiary,
: scheme.tertiary,
),
Text(
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
style: TextStyle(
style: MeshTheme.mono(
fontSize: 9 * textScale,
color: isOutgoing
? metaColor
: Theme.of(
context,
).colorScheme.tertiary,
: scheme.tertiary,
),
),
],
@@ -1454,8 +1519,8 @@ class _MessageBubble extends StatelessWidget {
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
child: _buildReactionsDisplay(context, message, colorScheme),
padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
child: _buildReactionsDisplay(context, message, scheme),
),
],
],
@@ -1532,7 +1597,7 @@ class _MessageBubble extends StatelessWidget {
Widget _buildReactionsDisplay(
BuildContext context,
Message message,
ColorScheme colorScheme,
ColorScheme scheme,
) {
return Wrap(
spacing: 6,
@@ -1555,28 +1620,33 @@ class _MessageBubble extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isFailed
? colorScheme.errorContainer
: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
? scheme.errorContainer
: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(MeshRadii.pill),
border: Border.all(
color: isFailed
? colorScheme.error
: colorScheme.outline.withValues(alpha: 0.3),
color: isFailed ? scheme.error : scheme.outlineVariant,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
Text(
emoji,
style: MeshTheme.emoji(fontSize: 16),
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.onSecondaryContainer,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
),
),
],
@@ -1587,13 +1657,13 @@ class _MessageBubble extends StatelessWidget {
height: 8,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: colorScheme.onSecondaryContainer,
color: scheme.primary,
),
),
],
if (isFailed) ...[
const SizedBox(width: 2),
Icon(Icons.replay, size: 10, color: colorScheme.error),
Icon(Icons.replay, size: 10, color: scheme.error),
],
],
),
@@ -1604,22 +1674,8 @@ class _MessageBubble extends StatelessWidget {
);
}
Widget _buildAvatar(String senderName, ColorScheme colorScheme) {
final initial = firstCharacterOrEmoji(senderName);
final color = colorForName(senderName);
return CircleAvatar(
radius: 18,
backgroundColor: color.withValues(alpha: 0.2),
child: Text(
initial,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: color,
),
),
);
Widget _buildAvatar(String senderName) {
return AvatarCircle(name: senderName, size: 32);
}
String _formatTime(DateTime time) {
@@ -1628,3 +1684,20 @@ class _MessageBubble extends StatelessWidget {
return '$hour:$minute';
}
}
/// Deterministic name-to-hue mapping consistent with [AvatarCircle].
Color _colorForName(String name) {
const hues = [
MeshPalette.blue,
MeshPalette.magenta,
MeshPalette.signal,
MeshPalette.warn,
Color(0xFF8FA8F0),
Color(0xFF6FD9CE),
];
var h = 0;
for (final c in name.codeUnits) {
h = (h * 31 + c) & 0x7fffffff;
}
return hues[h % hues.length];
}
+87 -68
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
class ChromeRequiredScreen extends StatelessWidget {
const ChromeRequiredScreen({super.key});
@@ -7,78 +9,95 @@ class ChromeRequiredScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final scheme = Theme.of(context).colorScheme;
return Scaffold(
body: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 32),
color: colorScheme.surface,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: Icon(
Icons.browser_not_supported_rounded,
size: 80,
color: colorScheme.tertiary,
),
),
const SizedBox(height: 32),
Text(
l10n.scanner_chromeRequired,
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 16),
Text(
l10n.scanner_chromeRequiredMessage,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
const SizedBox(height: 48),
// We can't really "fix" it for them other than telling them to use Chrome
// but we can provide a nice visual.
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.secondary,
),
const SizedBox(width: 12),
Text(
l10n.chrome_bluetoothRequiresChromium,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon in tinted circle
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.tertiary.withValues(alpha: 0.10),
border: Border.all(
color: scheme.tertiary.withValues(alpha: 0.25),
width: 1.5,
),
),
],
),
child: Icon(
Icons.browser_not_supported_rounded,
size: 42,
color: scheme.tertiary,
),
),
const SizedBox(height: 28),
// Title
Text(
l10n.scanner_chromeRequired,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: scheme.onSurface,
letterSpacing: -0.3,
),
),
const SizedBox(height: 12),
// Body text
Text(
l10n.scanner_chromeRequiredMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: scheme.onSurfaceVariant,
height: 1.55,
),
),
const SizedBox(height: 32),
// Info chip
MeshCard(
margin: EdgeInsets.zero,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
color: scheme.secondaryContainer.withValues(alpha: 0.35),
borderColor: scheme.outline.withValues(alpha: 0.3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.info_outline,
size: 18,
color: scheme.secondary,
),
const SizedBox(width: 10),
Flexible(
child: Text(
l10n.chrome_bluetoothRequiresChromium,
style: MeshTheme.mono(
fontSize: 12,
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
),
],
),
],
),
),
),
);
+223 -66
View File
@@ -1,14 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../connector/meshcore_connector.dart';
import '../helpers/snack_bar_builder.dart';
import '../l10n/l10n.dart';
import '../models/community.dart';
import '../storage/community_store.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/qr_scanner_widget.dart';
import '../helpers/snack_bar_builder.dart';
/// Screen for scanning community QR codes to join communities.
///
@@ -35,16 +39,90 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
centerTitle: true,
),
body: _isProcessing
? const Center(child: CircularProgressIndicator())
? Container(
color: Theme.of(context).colorScheme.surface,
child: const Center(child: CircularProgressIndicator()),
)
: QrScannerWidget(
onScanned: (data) => _handleScannedData(context, data),
validator: Community.isValidQrData,
onValidationFailed: (_) => _showInvalidQrError(context),
instructions: context.l10n.community_scanInstructions,
overlay: _buildThemedOverlay(context),
),
);
}
Widget _buildThemedOverlay(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
// Dark semi-transparent background with cutout
ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withValues(alpha: 0.5),
BlendMode.srcOut,
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: const BoxDecoration(
color: Colors.black,
backgroundBlendMode: BlendMode.dstOut,
),
),
Center(
child: Container(
height: 250,
width: 250,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(16),
),
),
),
],
),
),
// Corner brackets on top
const ScannerCornerOverlay(
scanWindowSize: 250,
borderColor: MeshPalette.blue,
borderWidth: 2,
cornerLength: 24,
),
// Instructions pill below the scan window
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 250 + 24),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(MeshRadii.pill),
),
child: Text(
context.l10n.community_scanInstructions,
style: const TextStyle(
color: MeshPalette.ink2,
fontSize: 13,
),
textAlign: TextAlign.center,
),
),
],
),
),
],
);
}
Future<void> _handleScannedData(BuildContext context, String data) async {
if (_isProcessing) return;
@@ -80,7 +158,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.red,
backgroundColor: MeshPalette.alert,
);
}
} finally {
@@ -96,29 +174,74 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_invalidQrCode),
backgroundColor: Colors.orange,
backgroundColor: MeshPalette.warn,
duration: const Duration(seconds: 2),
);
}
void _showAlreadyMemberDialog(BuildContext context, Community community) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.community_alreadyMember),
content: Text(
context.l10n.community_alreadyMemberMessage(community.name),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
Navigator.pop(context);
},
child: Text(context.l10n.common_ok),
showMeshSheet(
context,
builder: (sheetContext) {
final sheetScheme = Theme.of(sheetContext).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BottomSheetHeader(title: context.l10n.community_alreadyMember),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Text(
context.l10n.community_alreadyMemberMessage(community.name),
style: TextStyle(color: sheetScheme.onSurfaceVariant),
),
),
MeshCard(
child: Row(
children: [
const Icon(
Icons.groups,
color: MeshPalette.magenta,
size: 32,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
community.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
Text(
'ID: ${community.shortCommunityId}...',
style: MeshTheme.mono(
fontSize: 11.5,
color: sheetScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: FilledButton(
onPressed: () {
Navigator.pop(sheetContext);
Navigator.pop(context);
},
child: Text(context.l10n.common_ok),
),
),
],
),
);
},
);
}
@@ -127,38 +250,51 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
Community community,
) async {
bool addPublicChannel = true;
final completer = Completer<bool>();
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setDialogState) => AlertDialog(
title: Text(context.l10n.community_joinTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.community_joinConfirmation(community.name)),
const SizedBox(height: 16),
Row(
await showMeshSheet<void>(
context,
builder: (sheetContext) => StatefulBuilder(
builder: (sheetContext, setSheetState) {
final joinScheme = Theme.of(sheetContext).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BottomSheetHeader(title: context.l10n.community_joinTitle),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Text(
context.l10n.community_joinConfirmation(community.name),
style: TextStyle(color: joinScheme.onSurfaceVariant),
),
),
MeshCard(
child: Row(
children: [
Icon(
Icons.groups,
color: Theme.of(dialogContext).colorScheme.primary,
AvatarCircle(
name: community.name,
icon: Icons.groups,
color: MeshPalette.magenta,
size: 44,
),
const SizedBox(width: 12),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
community.name,
style: const TextStyle(fontWeight: FontWeight.bold),
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
Text(
'ID: ${community.shortCommunityId}...',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
style: MeshTheme.mono(
fontSize: 11.5,
color: joinScheme.onSurfaceVariant,
),
),
],
@@ -166,38 +302,59 @@ class _CommunityQrScannerScreenState extends State<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(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(context.l10n.community_join),
CheckboxListTile(
value: addPublicChannel,
onChanged: (value) {
setSheetState(() {
addPublicChannel = value ?? true;
});
},
title: Text(context.l10n.community_addPublicChannel),
subtitle: Text(context.l10n.community_addPublicChannelHint),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
completer.complete(false);
Navigator.pop(sheetContext);
},
child: Text(context.l10n.common_cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {
completer.complete(true);
Navigator.pop(sheetContext);
},
child: Text(context.l10n.community_join),
),
),
],
),
),
],
),
);
},
),
);
if (result == true && context.mounted) {
// If sheet was dismissed without a button press, treat as cancel
if (!completer.isCompleted) {
completer.complete(false);
}
final result = await completer.future;
if (result && context.mounted) {
await _joinCommunity(context, community, addPublicChannel);
} else if (context.mounted) {
// User cancelled - go back
@@ -231,7 +388,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
showDismissibleSnackBar(
context,
content: Text(context.l10n.community_joined(community.name)),
backgroundColor: Colors.green,
backgroundColor: MeshPalette.signal,
);
// 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/models/companion_radio_stats.dart';
import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/theme/mesh_theme.dart';
import 'package:meshcore_open/widgets/mesh_ui.dart';
import 'package:provider/provider.dart';
class CompanionRadioStatsScreen extends StatefulWidget {
@@ -49,6 +51,25 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
super.dispose();
}
Widget _tile(String text, IconData icon, Color color) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: MeshTheme.mono(fontSize: 13, color: scheme.onSurface),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@@ -85,44 +106,108 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
valueListenable: connector.radioStatsNotifier,
builder: (context, stats, _) {
return ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
if (stats != null) ...[
Text(
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
style: tt.titleMedium,
const SectionHeader(
'Signal',
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
),
const SizedBox(height: 4),
Text(l10n.radioStats_lastRssi(stats.lastRssiDbm)),
Text(
l10n.radioStats_lastSnr(
stats.lastSnrDb.toStringAsFixed(1),
MeshCard(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_tile(
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
Icons.noise_aware,
scheme.onSurfaceVariant,
),
const Divider(height: 1),
_tile(
l10n.radioStats_lastRssi(stats.lastRssiDbm),
Icons.wifi_tethering,
scheme.onSurfaceVariant,
),
const Divider(height: 1),
_tile(
l10n.radioStats_lastSnr(
stats.lastSnrDb.toStringAsFixed(1),
),
Icons.signal_cellular_alt,
MeshTheme.snrColor(
stats.lastSnrDb,
blocked: false,
),
),
],
),
),
Text(l10n.radioStats_txAir(stats.txAirSecs)),
Text(l10n.radioStats_rxAir(stats.rxAirSecs)),
const SizedBox(height: 16),
] else
Text(l10n.radioStats_waiting),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: CustomPaint(
painter: _NoiseChartPainter(
samples: List<double>.from(_noiseHistory),
colorScheme: scheme,
textTheme: tt,
const SectionHeader(
'Airtime',
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
),
MeshCard(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_tile(
l10n.radioStats_txAir(stats.txAirSecs),
Icons.upload,
MeshPalette.blue,
),
const Divider(height: 1),
_tile(
l10n.radioStats_rxAir(stats.rxAirSecs),
Icons.download,
MeshPalette.blue,
),
],
),
),
] else ...[
const SizedBox(height: 80),
Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 8),
Center(
child: Text(
l10n.radioStats_waiting,
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
],
SectionHeader(
l10n.radioStats_chartCaption,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: 200,
child: CustomPaint(
painter: _NoiseChartPainter(
samples: List<double>.from(_noiseHistory),
colorScheme: scheme,
textTheme: tt,
),
child: const SizedBox.expand(),
),
child: const SizedBox.expand(),
),
),
const SizedBox(height: 8),
Text(
l10n.radioStats_chartCaption,
style: tt.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
);
},
+225 -89
View File
@@ -16,6 +16,7 @@ import '../models/contact.dart';
import '../l10n/contact_localization.dart';
import '../models/contact_group.dart';
import '../services/ui_view_state_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/contact_search.dart';
import '../storage/contact_group_store.dart';
import '../utils/dialog_utils.dart';
@@ -24,12 +25,12 @@ import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart';
import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/quick_switch_bar.dart';
import '../widgets/repeater_login_dialog.dart';
import '../widgets/room_login_dialog.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/unread_badge.dart';
import '../helpers/contact_ui.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'chat_screen.dart';
@@ -472,12 +473,13 @@ class _ContactsScreenState extends State<ContactsScreen>
}
void _showAddContactSheet(BuildContext context) {
showModalBottomSheet(
context: context,
showMeshSheet(
context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(title: context.l10n.contacts_title),
ListTile(
leading: const Icon(Icons.paste),
title: Text(context.l10n.contacts_addContactFromClipboard),
@@ -499,6 +501,7 @@ class _ContactsScreenState extends State<ContactsScreen>
);
},
),
const SizedBox(height: 8),
],
),
),
@@ -909,7 +912,8 @@ class _ContactsScreenState extends State<ContactsScreen>
final unreadCount = connector.getUnreadCountForContact(
contact,
);
return _ContactTile(
return _ContactTileEntrance(
index: index,
contact: contact,
lastSeen: _resolveLastSeen(contact),
unreadCount: unreadCount,
@@ -1343,17 +1347,22 @@ class _ContactsScreenState extends State<ContactsScreen>
final isRoom = contact.type == advTypeRoom;
final isFavorite = contact.isFavorite;
showModalBottomSheet(
context: context,
showMeshSheet(
context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(
title: contact.name,
subtitle: contact.typeLabel(context.l10n),
),
if (isRepeater) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_ping),
onTap: () {
Navigator.pop(sheetContext);
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
@@ -1371,7 +1380,7 @@ class _ContactsScreenState extends State<ContactsScreen>
},
),
ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange),
leading: Icon(Icons.cell_tower, color: MeshPalette.warn),
title: Text(context.l10n.contacts_manageRepeater),
onTap: () {
Navigator.pop(sheetContext);
@@ -1380,9 +1389,10 @@ class _ContactsScreenState extends State<ContactsScreen>
),
] else if (isRoom) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_pathTrace),
onTap: () {
Navigator.pop(sheetContext);
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
@@ -1405,7 +1415,7 @@ class _ContactsScreenState extends State<ContactsScreen>
},
),
ListTile(
leading: const Icon(Icons.room, color: Colors.blue),
leading: Icon(Icons.meeting_room, color: MeshPalette.blue),
title: Text(context.l10n.contacts_roomLogin),
onTap: () {
Navigator.pop(sheetContext);
@@ -1413,10 +1423,7 @@ class _ContactsScreenState extends State<ContactsScreen>
},
),
ListTile(
leading: const Icon(
Icons.room_preferences,
color: Colors.orange,
),
leading: Icon(Icons.room_preferences, color: MeshPalette.warn),
title: Text(context.l10n.room_management),
onTap: () {
Navigator.pop(sheetContext);
@@ -1430,9 +1437,10 @@ class _ContactsScreenState extends State<ContactsScreen>
] else ...[
if (contact.pathLength > 0)
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
leading: Icon(Icons.radar, color: MeshPalette.signal),
title: Text(context.l10n.contacts_chatTraceRoute),
onTap: () {
Navigator.pop(sheetContext);
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
@@ -1456,7 +1464,7 @@ class _ContactsScreenState extends State<ContactsScreen>
ListTile(
leading: Icon(
isFavorite ? Icons.star : Icons.star_border,
color: Colors.amber[700],
color: MeshPalette.warn,
),
title: Text(
isFavorite
@@ -1501,6 +1509,7 @@ class _ContactsScreenState extends State<ContactsScreen>
_confirmDelete(context, connector, contact);
},
),
const SizedBox(height: 8),
],
),
),
@@ -1555,82 +1564,173 @@ class _ContactTile extends StatelessWidget {
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
child: ListTile(
leading: CircleAvatar(
backgroundColor: contactTypeColor(contact.type),
child: _buildContactAvatar(contact),
),
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
contact.pathLabel(context.l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
trailing: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
),
child: SizedBox(
width: 96,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(height: 4),
],
Text(
_formatLastSeen(context, lastSeen),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isFavorite)
Icon(Icons.star, size: 14, color: Colors.amber[700]),
if (isFavorite && contact.hasLocation)
const SizedBox(width: 2),
if (contact.hasLocation)
Icon(
Icons.location_on,
size: 14,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
],
),
],
),
),
),
onTap: onTap,
onLongPress: onLongPress,
),
);
/// Node-type avatar color per design language.
Color _avatarColor() {
switch (contact.type) {
case advTypeRepeater:
return MeshPalette.warn;
case advTypeRoom:
return MeshPalette.magenta;
case advTypeSensor:
return const Color(0xFF4ACCC4); // teal
default:
return MeshPalette.blue; // chat AvatarCircle handles deterministic hue
}
}
Widget _buildContactAvatar(Contact contact) {
final emoji = firstEmoji(contact.name);
if (emoji != null) {
return Text(emoji, style: const TextStyle(fontSize: 18));
/// Node-type avatar icon. Returns null for chat nodes so AvatarCircle shows initials.
IconData? _avatarIcon() {
switch (contact.type) {
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.meeting_room;
case advTypeSensor:
return Icons.sensors;
default:
return null; // chat uses initials
}
return Icon(contactTypeIcon(contact.type), color: Colors.white, size: 20);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final emoji = firstEmoji(contact.name);
final isChat = contact.type == advTypeChat;
final pathLen = contact.pathBytesForDisplay.length;
final isDirect = contact.pathLength >= 0;
final hasPath = pathLen > 0 || contact.pathLength == 0;
return GestureDetector(
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
child: MeshCard(
onTap: onTap,
onLongPress: onLongPress,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
// Avatar
if (emoji != null)
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.surfaceContainerHigh,
border: Border.all(color: scheme.outlineVariant),
),
alignment: Alignment.center,
child: Text(emoji, style: const TextStyle(fontSize: 20)),
)
else
AvatarCircle(
name: contact.name,
size: 42,
color: isChat ? null : _avatarColor(),
icon: _avatarIcon(),
),
const SizedBox(width: 12),
// Main content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Name row + route chip
Row(
children: [
Expanded(
child: Text(
contact.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: unreadCount > 0
? FontWeight.w700
: FontWeight.w500,
fontSize: 15,
color: scheme.onSurface,
),
),
),
if (isFavorite) ...[
const SizedBox(width: 4),
Icon(Icons.star, size: 13, color: MeshPalette.warn),
],
if (contact.hasLocation) ...[
const SizedBox(width: 4),
Icon(
Icons.location_on,
size: 13,
color: scheme.onSurfaceVariant.withValues(alpha: 0.55),
),
],
],
),
const SizedBox(height: 3),
// Path / subtitle row
Row(
children: [
Expanded(
child: Text(
contact.pathLabel(context.l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
),
if (hasPath) ...[
const SizedBox(width: 6),
RouteChip(
isDirect: isDirect,
hops: isDirect ? contact.pathLength : null,
),
],
],
),
],
),
),
const SizedBox(width: 10),
// Trailing: time + unread badge
// Clamp text scale to prevent overflow in trailing section.
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (unreadCount > 0) ...[
UnreadBadge(count: unreadCount),
const SizedBox(height: 4),
],
Text(
_formatLastSeen(context, lastSeen),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: MeshTheme.mono(
fontSize: 11,
color: unreadCount > 0
? MeshPalette.blue
: scheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
@@ -1655,3 +1755,39 @@ class _ContactTile extends StatelessWidget {
: context.l10n.contacts_lastSeenDaysAgo(days);
}
}
// Wrap each contact tile with staggered entrance.
class _ContactTileEntrance extends StatelessWidget {
final int index;
final Contact contact;
final DateTime lastSeen;
final int unreadCount;
final bool isFavorite;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _ContactTileEntrance({
required this.index,
required this.contact,
required this.lastSeen,
required this.unreadCount,
required this.isFavorite,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return ListEntrance(
index: index,
child: _ContactTile(
contact: contact,
lastSeen: lastSeen,
unreadCount: unreadCount,
isFavorite: isFavorite,
onTap: onTap,
onLongPress: onLongPress,
),
);
}
}
+189 -109
View File
@@ -7,12 +7,14 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../l10n/contact_localization.dart';
import '../models/contact.dart';
import '../theme/mesh_theme.dart';
import '../utils/contact_search.dart';
import '../utils/platform_info.dart';
import '../widgets/app_bar.dart';
import '../widgets/list_filter_widget.dart';
import '../helpers/contact_ui.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart';
enum DiscoverySortOption { lastSeen, name, type }
@@ -47,6 +49,34 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
: contact.lastSeen;
}
/// Node-type avatar color per design language.
Color _avatarColor(int type) {
switch (type) {
case advTypeRepeater:
return MeshPalette.warn;
case advTypeRoom:
return MeshPalette.magenta;
case advTypeSensor:
return const Color(0xFF4ACCC4); // teal
default:
return MeshPalette.blue;
}
}
/// Node-type avatar icon; null = show initials for chat nodes.
IconData? _avatarIcon(int type) {
switch (type) {
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.meeting_room;
case advTypeSensor:
return Icons.sensors;
default:
return null;
}
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@@ -93,121 +123,167 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
children: [
_buildFilters(filteredAndSorted, connector),
Expanded(
child: discoveredContacts.isEmpty
? Center(child: Text(l10n.contacts_noContacts))
: filteredAndSorted.isEmpty
? Center(child: Text(l10n.discoveredContacts_noMatching))
: ListView.builder(
itemCount: filteredAndSorted.length,
itemBuilder: (context, index) {
final contact = filteredAndSorted[index];
final tile = ListTile(
leading: CircleAvatar(
backgroundColor: contactTypeColor(contact.type),
child: Icon(
contactTypeIcon(contact.type),
color: Colors.white,
size: 20,
),
),
title: Text(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
child: discoveredContacts.isEmpty
? Center(
key: const ValueKey('empty_all'),
child: Text(l10n.contacts_noContacts),
)
: filteredAndSorted.isEmpty
? Center(
key: const ValueKey('empty_filtered'),
child: Text(l10n.discoveredContacts_noMatching),
)
: ListView.builder(
key: const ValueKey('list'),
padding: const EdgeInsets.only(bottom: 24),
itemCount: filteredAndSorted.length,
itemBuilder: (context, index) {
final contact = filteredAndSorted[index];
final tile = _buildDiscoveryTile(
context,
contact,
connector,
index,
);
if (PlatformInfo.isDesktop) {
return GestureDetector(
onSecondaryTapUp: (_) =>
_showContactContextMenu(contact, connector),
child: tile,
);
}
return tile;
},
),
),
),
],
),
);
}
Widget _buildDiscoveryTile(
BuildContext context,
Contact contact,
MeshCoreConnector connector,
int index,
) {
final scheme = Theme.of(context).colorScheme;
final isChat = contact.type == advTypeChat;
return ListEntrance(
index: index,
child: MeshCard(
onTap: () {
connector.importDiscoveredContact(contact);
showDismissibleSnackBar(
context,
content: Text(
context.l10n.discoveredContacts_contactAdded,
),
action: SnackBarAction(
label: context.l10n.common_undo,
onPressed: () => connector.removeContact(contact),
),
);
},
onLongPress: () => _showContactContextMenu(contact, connector),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
AvatarCircle(
name: contact.name,
size: 42,
color: isChat ? null : _avatarColor(contact.type),
icon: _avatarIcon(contact.type),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Name + type chip
Row(
children: [
Expanded(
child: Text(
contact.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15,
),
),
subtitle: Text(
),
const SizedBox(width: 6),
StatusChip(
label: contact.typeLabel(context.l10n).toUpperCase(),
color: _avatarColor(contact.type),
icon: _avatarIcon(contact.type),
),
],
),
const SizedBox(height: 3),
// Short pub key
Row(
children: [
Expanded(
child: Text(
contact.shortPubKeyHex,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
trailing: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.3),
),
),
child: SizedBox(
width: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatLastSeen(
context,
_resolveLastSeen(contact),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.hasLocation)
Icon(
Icons.location_on,
size: 14,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.6),
),
if (contact.rawPacket != null)
const SizedBox(width: 2),
if (contact.rawPacket != null)
Icon(
Icons.cell_tower,
size: 14,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.6),
),
],
),
],
),
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
),
onTap: () {
connector.importDiscoveredContact(contact);
showDismissibleSnackBar(
context,
content: Text(
context.l10n.discoveredContacts_contactAdded,
),
action: SnackBarAction(
label: context.l10n.common_undo,
onPressed: () => connector.removeContact(contact),
),
);
},
onLongPress: () =>
_showContactContextMenu(contact, connector),
);
if (PlatformInfo.isDesktop) {
return GestureDetector(
onSecondaryTapUp: (_) =>
_showContactContextMenu(contact, connector),
child: tile,
);
}
return tile;
},
),
if (contact.hasLocation) ...[
const SizedBox(width: 6),
Icon(
Icons.location_on,
size: 13,
color: scheme.onSurfaceVariant.withValues(alpha: 0.55),
),
],
if (contact.rawPacket != null) ...[
const SizedBox(width: 4),
Icon(
Icons.cell_tower,
size: 13,
color: scheme.onSurfaceVariant.withValues(alpha: 0.55),
),
],
],
),
),
],
],
),
),
const SizedBox(width: 10),
// Last seen time
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
),
),
child: Text(
_formatLastSeen(context, _resolveLastSeen(contact)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
),
),
],
),
),
);
}
@@ -216,15 +292,18 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
Contact contact,
MeshCoreConnector connector,
) async {
final action = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
final action = await showMeshSheet<String>(
context,
builder: (sheetContext) {
final l10n = context.l10n;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(
title: contact.name,
subtitle: contact.typeLabel(l10n),
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(l10n.discoveredContacts_copyContact),
@@ -235,6 +314,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
title: Text(l10n.discoveredContacts_deleteContact),
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 '../widgets/adaptive_app_bar_title.dart';
import '../helpers/snack_bar_builder.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/themed_map_tile_layer.dart';
class MapCacheScreen extends StatefulWidget {
const MapCacheScreen({super.key});
@@ -76,27 +79,34 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
return Positioned(
top: 12,
left: 12,
child: Card(
elevation: 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: context.l10n.map_centerMap,
onPressed: _resetMapView,
),
],
child: DecoratedBox(
decoration: BoxDecoration(
color: MeshPalette.bg1.withValues(alpha: 0.90),
borderRadius: BorderRadius.circular(MeshRadii.md),
border: Border.all(color: MeshPalette.line2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(MeshRadii.md),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: context.l10n.map_centerMap,
onPressed: _resetMapView,
),
],
),
),
),
);
@@ -281,6 +291,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
final tileCache = context.read<MapTileCacheService>();
final selectedBounds = _selectedBounds;
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
final progressValue = _estimatedTiles == 0
? 0.0
@@ -318,13 +329,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
),
),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
ThemedMapTileLayer(tileCache: tileCache),
if (selectedBounds != null)
PolygonLayer(
polygons: [
@@ -342,14 +347,25 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
Positioned(
top: 12,
right: 12,
child: Card(
child: DecoratedBox(
decoration: BoxDecoration(
color: MeshPalette.bg1.withValues(alpha: 0.93),
borderRadius: BorderRadius.circular(MeshRadii.md),
border: Border.all(color: MeshPalette.line2),
),
child: Padding(
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text(
selectedBounds == null
? l10n.mapCache_noAreaSelected
: _formatBounds(selectedBounds, l10n),
style: const TextStyle(fontSize: 12),
style: MeshTheme.mono(
fontSize: 11,
color: MeshPalette.ink2,
),
),
),
),
@@ -359,111 +375,133 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.mapCache_cacheArea,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
border: Border(top: BorderSide(color: scheme.outlineVariant)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SectionHeader(
l10n.mapCache_cacheArea,
padding: const EdgeInsets.fromLTRB(0, 12, 0, 8),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.crop_free),
label: Text(l10n.mapCache_useCurrentView),
onPressed: _isDownloading ? null : _setBoundsFromView,
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.crop_free),
label: Text(l10n.mapCache_useCurrentView),
onPressed: _isDownloading
? null
: _setBoundsFromView,
),
),
),
const SizedBox(width: 12),
TextButton(
onPressed: _isDownloading || selectedBounds == null
? null
: _clearBounds,
child: Text(l10n.common_clear),
),
],
),
const SizedBox(height: 12),
Text(
l10n.mapCache_zoomRange,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
RangeSlider(
values: RangeValues(
_minZoom.toDouble(),
_maxZoom.toDouble(),
),
min: 3,
max: 18,
divisions: 15,
labels: RangeLabels('$_minZoom', '$_maxZoom'),
onChanged: _isDownloading
? null
: (values) {
setState(() {
_minZoom = values.start.round();
_maxZoom = values.end.round();
});
},
onChangeEnd: _isDownloading
? null
: (_) {
_saveZoomRange();
},
),
Text(l10n.mapCache_estimatedTiles(_estimatedTiles)),
if (_isDownloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: progressValue),
const SizedBox(height: 4),
Text(
l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
),
),
],
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.download),
label: Text(l10n.mapCache_downloadTilesButton),
const SizedBox(width: 12),
TextButton(
onPressed: _isDownloading || selectedBounds == null
? null
: _startDownload,
: _clearBounds,
child: Text(l10n.common_clear),
),
],
),
const SizedBox(height: 12),
SectionHeader(
l10n.mapCache_zoomRange,
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
),
RangeSlider(
values: RangeValues(
_minZoom.toDouble(),
_maxZoom.toDouble(),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: _isDownloading ? null : _clearCache,
child: Text(l10n.mapCache_clearCacheButton),
),
],
),
if (_failedTiles > 0 && !_isDownloading)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.mapCache_failedDownloads(_failedTiles),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
min: 3,
max: 18,
divisions: 15,
labels: RangeLabels('$_minZoom', '$_maxZoom'),
onChanged: _isDownloading
? null
: (values) {
setState(() {
_minZoom = values.start.round();
_maxZoom = values.end.round();
});
},
onChangeEnd: _isDownloading
? null
: (_) {
_saveZoomRange();
},
),
Text(
l10n.mapCache_estimatedTiles(_estimatedTiles),
style: MeshTheme.mono(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
],
if (_isDownloading) ...[
const SizedBox(height: 8),
LinearProgressIndicator(
value: progressValue,
color: MeshPalette.blue,
backgroundColor: scheme.surfaceContainerHighest,
),
const SizedBox(height: 4),
Text(
l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
),
style: MeshTheme.mono(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.download),
label: Text(l10n.mapCache_downloadTilesButton),
onPressed: _isDownloading || selectedBounds == null
? null
: _startDownload,
),
),
const SizedBox(width: 12),
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: MeshPalette.alert,
side: const BorderSide(
color: MeshPalette.alertLine,
),
),
onPressed: _isDownloading ? null : _clearCache,
child: Text(l10n.mapCache_clearCacheButton),
),
],
),
if (_failedTiles > 0 && !_isDownloading)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.mapCache_failedDownloads(_failedTiles),
style: MeshTheme.mono(
fontSize: 12,
color: MeshPalette.alert,
),
),
),
],
),
),
),
),
+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_protocol.dart';
import '../services/repeater_command_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/routing_sheet.dart';
import '../widgets/snr_indicator.dart';
import '../helpers/snack_bar_builder.dart';
class NeighborsScreen extends StatefulWidget {
@@ -321,7 +322,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
child: RefreshIndicator(
onRefresh: _loadNeighbors,
child: ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
if (!_isLoaded &&
!_hasData &&
@@ -330,9 +331,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
if (_isLoaded ||
_hasData &&
!(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
_buildNeighborsInfoCard(
"${l10n.repeater_neighbors} - $_neighborCount",
),
_buildNeighborsList(connector),
],
),
),
@@ -340,81 +339,100 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
);
}
Widget _buildNeighborsInfoCard(String title) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
Widget _buildNeighborsList(MeshCoreConnector connector) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionHeader(
'${l10n.repeater_neighbors}$_neighborCount',
padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
),
for (var i = 0; i < _parsedNeighbors!.length; i++)
ListEntrance(
index: i,
child: _buildNeighborRow(
_parsedNeighbors![i],
connector.currentSf,
),
),
],
);
}
Widget _buildNeighborRow(Map<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: [
Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
title,
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w500,
fontSize: 15,
),
),
const SizedBox(height: 2),
Text(
heardLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const Divider(),
for (final entry in _parsedNeighbors!.asMap().entries)
_buildInfoRow(
entry.value['contact'] != null
? entry.value['contact'].name
: context.l10n.neighbors_unknownContact(
"<${pubKeyToHex(entry.value['publicKey'])}>",
),
context.l10n.neighbors_heardAgo(
fmtDuration(entry.value['lastHeard'] + 0.0),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
SignalBars(snr: snr, height: 16),
const SizedBox(height: 4),
Text(
'${snr.toStringAsFixed(1)} dB',
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: snrColor,
),
entry.value['snr'],
connector.currentSf,
),
],
),
),
);
}
Widget _buildInfoRow(
String label,
String value,
double snr,
int? spreadingFactor,
) {
final snrUi = snrUiFromSNR(snr, spreadingFactor);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(value),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(snrUi.icon, color: snrUi.color, size: 18.0),
Text(
snrUi.text,
style: TextStyle(fontSize: 10, color: snrUi.color),
),
],
),
),
],
),
],
),
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/services.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../l10n/contact_localization.dart';
import '../services/app_settings_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import 'repeater_status_screen.dart';
import 'repeater_cli_screen.dart';
import 'repeater_settings_screen.dart';
@@ -26,189 +29,157 @@ class RepeaterHubScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final settingsService = context.watch<AppSettingsService>();
final chemistry = settingsService.batteryChemistryForRepeater(
repeater.publicKeyHex,
);
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (isAdmin)
Text(
repeater.type == advTypeRepeater
? l10n.repeater_management
: l10n.room_management,
),
if (!isAdmin)
Text(
repeater.type == advTypeRepeater
? l10n.repeater_guest
: l10n.room_guest,
),
Text(
repeater.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
],
title: Text(
repeater.type == advTypeRepeater
? (isAdmin ? l10n.repeater_management : l10n.repeater_guest)
: (isAdmin ? l10n.room_management : l10n.room_guest),
),
centerTitle: false,
centerTitle: true,
),
body: SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.only(bottom: 24),
children: [
// Repeater info card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
// Identity card
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
child: MeshCard(
margin: EdgeInsets.zero,
padding: const EdgeInsets.all(20),
child: Row(
children: [
CircleAvatar(
radius: 40,
backgroundColor: Theme.of(
context,
).colorScheme.tertiaryContainer,
child: Icon(
Icons.cell_tower,
size: 40,
color: Theme.of(
context,
).colorScheme.onTertiaryContainer,
),
AvatarCircle(
name: repeater.name,
size: 52,
color: MeshPalette.warn,
icon: Icons.cell_tower,
),
const SizedBox(height: 16),
Text(
repeater.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
repeater.shortPubKeyHex,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
repeater.pathLabel(context.l10n),
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (repeater.hasLocation) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.location_on,
size: 14,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
style: TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
repeater.name,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
repeater.shortPubKeyHex,
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
repeater.pathLabel(l10n),
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: scheme.onSurfaceVariant),
),
if (repeater.hasLocation) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.location_on,
size: 12,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 3),
Expanded(
child: Text(
'${repeater.latitude?.toStringAsFixed(4)}, '
'${repeater.longitude?.toStringAsFixed(4)}',
style: MeshTheme.mono(
fontSize: 10,
color: scheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
],
),
StatusChip(
label: isAdmin ? 'ADMIN' : 'GUEST',
color: isAdmin ? MeshPalette.blue : scheme.onSurfaceVariant,
),
],
),
),
),
const SizedBox(height: 24),
if (isAdmin)
Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.battery_full),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.appSettings_batteryChemistry,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
DropdownButtonFormField<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),
),
],
),
],
// Battery chemistry (admin only)
if (isAdmin) ...[
SectionHeader(l10n.appSettings_batteryChemistry),
MeshCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.fromLTRB(14, 10, 14, 14),
child: DropdownButtonFormField<String>(
initialValue: chemistry,
isExpanded: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.battery_full, size: 18),
labelText: l10n.appSettings_batteryChemistry,
),
onChanged: (value) {
if (value == null) return;
settingsService.setBatteryChemistryForRepeater(
repeater.publicKeyHex,
value,
);
},
items: [
DropdownMenuItem(
value: 'nmc',
child: Text(l10n.appSettings_batteryNmc),
),
DropdownMenuItem(
value: 'lifepo4',
child: Text(l10n.appSettings_batteryLifepo4),
),
DropdownMenuItem(
value: 'lipo',
child: Text(l10n.appSettings_batteryLipo),
),
],
),
),
const SizedBox(height: 24),
Text(
isAdmin
? l10n.repeater_managementTools
: l10n.repeater_guestTools,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
],
// Tools
SectionHeader(
isAdmin ? l10n.repeater_managementTools : l10n.repeater_guestTools,
),
const SizedBox(height: 16),
// Status button
_buildManagementCard(
context,
_HubActionTile(
index: 0,
icon: Icons.analytics,
title: l10n.repeater_status,
subtitle: l10n.repeater_statusSubtitle,
color: Theme.of(context).colorScheme.primary,
accentColor: MeshPalette.blue,
onTap: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
@@ -220,15 +191,15 @@ class RepeaterHubScreen extends StatelessWidget {
);
},
),
const SizedBox(height: 16),
// Telemetry button
_buildManagementCard(
context,
_HubActionTile(
index: 1,
icon: Icons.bar_chart_sharp,
title: l10n.repeater_telemetry,
subtitle: l10n.repeater_telemetrySubtitle,
color: Theme.of(context).colorScheme.secondary,
accentColor: MeshPalette.magenta,
onTap: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
@@ -237,16 +208,34 @@ class RepeaterHubScreen extends StatelessWidget {
);
},
),
if (isAdmin) const SizedBox(height: 12),
// CLI button
if (isAdmin)
_buildManagementCard(
context,
_HubActionTile(
index: 2,
icon: Icons.group,
title: l10n.repeater_neighbors,
subtitle: l10n.repeater_neighborsSubtitle,
accentColor: MeshPalette.signal,
onTap: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
NeighborsScreen(repeater: repeater, password: password),
),
);
},
),
if (isAdmin) ...[
_HubActionTile(
index: 3,
icon: Icons.terminal,
title: l10n.repeater_cli,
subtitle: l10n.repeater_cliSubtitle,
color: Theme.of(context).colorScheme.tertiary,
accentColor: MeshPalette.warn,
onTap: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
@@ -258,34 +247,14 @@ class RepeaterHubScreen extends StatelessWidget {
);
},
),
const SizedBox(height: 12),
// Neighbors button
_buildManagementCard(
context,
icon: Icons.group,
title: l10n.repeater_neighbors,
subtitle: l10n.repeater_neighborsSubtitle,
color: Theme.of(context).colorScheme.tertiary,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
NeighborsScreen(repeater: repeater, password: password),
),
);
},
),
if (isAdmin) const SizedBox(height: 12),
// Settings button
if (isAdmin)
_buildManagementCard(
context,
_HubActionTile(
index: 4,
icon: Icons.settings,
title: l10n.repeater_settings,
subtitle: l10n.repeater_settingsSubtitle,
color: Theme.of(context).colorScheme.error,
accentColor: MeshPalette.alert,
onTap: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
@@ -297,66 +266,82 @@ class RepeaterHubScreen extends StatelessWidget {
);
},
),
],
],
),
),
);
}
}
Widget _buildManagementCard(
BuildContext context, {
required IconData icon,
required String title,
required String subtitle,
required Color color,
required VoidCallback onTap,
}) {
return Card(
elevation: 2,
child: InkWell(
class _HubActionTile extends StatelessWidget {
final int index;
final IconData icon;
final String title;
final String subtitle;
final Color accentColor;
final VoidCallback onTap;
const _HubActionTile({
required this.index,
required this.icon,
required this.title,
required this.subtitle,
required this.accentColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ListEntrance(
index: index,
child: MeshCard(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 32),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(MeshRadii.md),
border: Border.all(
color: accentColor.withValues(alpha: 0.3),
),
),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurfaceVariant,
alignment: Alignment.center,
child: Icon(icon, size: 22, color: accentColor),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12.5,
color: scheme.onSurfaceVariant,
),
),
],
),
],
),
),
Icon(
Icons.chevron_right,
color: scheme.onSurfaceVariant,
size: 20,
),
],
),
),
);
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:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
@@ -10,7 +11,9 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/app_settings_service.dart';
import '../services/repeater_command_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/battery_utils.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/routing_sheet.dart';
import '../helpers/snack_bar_builder.dart';
@@ -64,8 +67,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector);
_setupMessageListener();
// Defer until after the first frame so any notifyListeners() triggered
// during preparePathForContactSend doesn't fire mid-build.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadStatus();
});
@@ -81,12 +82,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == pushCodeStatusResponse) {
_handleStatusResponse(frame);
} else if (frame[0] == respCodeContactMsgRecv ||
@@ -118,11 +115,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final parsed = parseContactMessageText(frame);
if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text);
// Parse status responses
_parseStatusResponse(parsed.text);
_recordStatusResult(true);
}
@@ -131,7 +124,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
if (frame.length < 8) return;
final prefix = frame.sublist(2, 8);
if (!_matchesRepeaterPrefix(prefix)) return;
if (frame.length < _statusResponseBytes) return;
final data = ByteData.sublistView(
@@ -254,14 +246,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_dupFlood = _asInt(data['dup_flood']);
_dupDirect = _asInt(data['dup_direct']);
}
} catch (_) {
// Ignore parse failures for non-JSON responses.
}
}
if (mounted) {
setState(() {});
} catch (_) {}
}
if (mounted) setState(() {});
}
Future<void> _loadStatus() async {
@@ -302,9 +289,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
var messageBytes = frame.length >= _statusResponseBytes
? frame.length
: _statusResponseBytes;
if (messageBytes < maxFrameSize) {
messageBytes = maxFrameSize;
}
if (messageBytes < maxFrameSize) messageBytes = maxFrameSize;
final timeoutMs = connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: messageBytes,
@@ -312,9 +297,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_statusTimeout?.cancel();
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
if (!mounted) return;
setState(() {
_isLoading = false;
});
setState(() => _isLoading = false);
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_statusRequestTimeout),
@@ -324,10 +307,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
});
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
setState(() => _isLoading = false);
showDismissibleSnackBar(
context,
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
@@ -347,214 +327,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_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) {
if (value == null) return null;
if (value is int) return value;
@@ -661,4 +433,218 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
if (snr == null) return '';
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 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_info.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:provider/provider.dart';
@@ -7,10 +8,12 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/linux_ble_error_classifier.dart';
import '../theme/mesh_theme.dart';
import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart';
import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'tcp_screen.dart';
@@ -136,12 +139,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
builder: (context, connector, child) {
return Column(
children: [
// Bluetooth off warning
if (_bluetoothState == BluetoothAdapterState.off)
_bluetoothOffWarning(context),
// Bluetooth off warning slides in/out with AnimatedSize
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: _bluetoothState == BluetoothAdapterState.off
? _BluetoothOffBanner(
onEnable: PlatformInfo.isAndroid
? () => FlutterBluePlus.turnOn()
: null,
)
: const SizedBox.shrink(),
),
// Status bar
_buildStatusBar(context, connector),
// Connection status header
_ConnectionStatusHeader(connector: connector),
// Device list
Expanded(child: _buildDeviceList(context, connector)),
@@ -158,14 +170,31 @@ class _ScannerScreenState extends State<ScannerScreen> {
return FloatingActionButton.extended(
heroTag: 'scanner_ble_action',
onPressed: isBluetoothOff ? null : () => _toggleScan(connector),
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.bluetooth_searching),
onPressed: isBluetoothOff
? null
: () {
HapticFeedback.lightImpact();
_toggleScan(connector);
},
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, anim) =>
ScaleTransition(scale: anim, child: child),
child: isScanning
? SizedBox(
key: const ValueKey('scanning'),
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
),
)
: const Icon(
Icons.bluetooth_searching,
key: ValueKey('idle'),
),
),
label: Text(
isScanning
? context.l10n.scanner_stop
@@ -189,51 +218,6 @@ class _ScannerScreenState extends State<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) {
if (connector.scanResults.isEmpty) {
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
@@ -251,7 +235,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
action: (isBluetoothOff || isScanning)
? null
: FilledButton.icon(
onPressed: () => _toggleScan(connector),
onPressed: () {
HapticFeedback.lightImpact();
_toggleScan(connector);
},
icon: const Icon(Icons.bluetooth_searching),
label: Text(context.l10n.scanner_scan),
),
@@ -259,19 +246,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
}
final isConnecting = connector.state == MeshCoreConnectionState.connecting;
return ListView.separated(
padding: const EdgeInsets.all(8),
return ListView.builder(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
itemCount: connector.scanResults.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final result = connector.scanResults[index];
final deviceId = result.device.remoteId.toString();
return DeviceTile(
scanResult: result,
isConnecting: isConnecting && _connectingDeviceId == deviceId,
onTap: isConnecting
? null
: () => _connectToDevice(context, connector, result),
return ListEntrance(
index: index,
child: DeviceTile(
scanResult: result,
isConnecting: isConnecting && _connectingDeviceId == deviceId,
onTap: isConnecting
? null
: () => _connectToDevice(context, connector, result),
),
);
},
);
@@ -413,47 +402,117 @@ class _ScannerScreenState extends State<ScannerScreen> {
);
return pin;
}
}
Widget _bluetoothOffWarning(BuildContext context) {
final errorColor = Theme.of(context).colorScheme.error;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
color: errorColor.withValues(alpha: 0.15),
// Private sub-widgets
/// Bluetooth-off warning banner styled as an alert MeshCard.
class _BluetoothOffBanner extends StatelessWidget {
final VoidCallback? onEnable;
const _BluetoothOffBanner({this.onEnable});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return MeshCard(
color: scheme.error.withValues(alpha: 0.08),
borderColor: scheme.error.withValues(alpha: 0.35),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Icon(Icons.bluetooth_disabled, size: 24, color: errorColor),
const SizedBox(width: 12),
Icon(Icons.bluetooth_disabled, size: 20, color: scheme.error),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.scanner_bluetoothOff,
style: TextStyle(
color: errorColor,
color: scheme.error,
fontWeight: FontWeight.w600,
fontSize: 14,
fontSize: 13.5,
),
),
const SizedBox(height: 4),
const SizedBox(height: 2),
Text(
context.l10n.scanner_bluetoothOffMessage,
style: TextStyle(
color: errorColor.withValues(alpha: 0.85),
color: scheme.error.withValues(alpha: 0.8),
fontSize: 12,
),
),
],
),
),
if (PlatformInfo.isAndroid)
if (onEnable != null) ...[
const SizedBox(width: 8),
TextButton(
onPressed: () => FlutterBluePlus.turnOn(),
onPressed: onEnable,
child: Text(context.l10n.scanner_enableBluetooth),
),
],
],
),
);
}
}
/// Connection status header with AnimatedSwitcher between states.
class _ConnectionStatusHeader extends StatelessWidget {
final MeshCoreConnector connector;
const _ConnectionStatusHeader({required this.connector});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final (String label, Color color, bool pulse) = switch (connector.state) {
MeshCoreConnectionState.scanning => (
l10n.scanner_scanning,
MeshPalette.blue,
true,
),
MeshCoreConnectionState.connecting => (
l10n.scanner_connecting,
MeshPalette.warn,
true,
),
MeshCoreConnectionState.connected => (
l10n.scanner_connectedTo(connector.deviceDisplayName),
MeshPalette.signal,
false,
),
MeshCoreConnectionState.disconnecting => (
l10n.scanner_disconnecting,
MeshPalette.warn,
true,
),
MeshCoreConnectionState.disconnected => (
l10n.scanner_notConnected,
scheme.onSurfaceVariant,
false,
),
};
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Align(
key: ValueKey(connector.state),
alignment: Alignment.centerLeft,
child: StatusChip(
label: label,
color: color,
pulse: pulse,
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+100 -56
View File
@@ -1,13 +1,16 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../theme/mesh_theme.dart';
import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'usb_screen.dart';
@@ -95,15 +98,32 @@ class _TcpScreenState extends State<TcpScreen> {
final isConnecting =
connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.tcp;
// A running BLE scan must not block TCP connect: connectTcp() stops
// any active scan before connecting, so the only reason to disable
// the button is a TCP connect already in flight.
final isButtonDisabled = isConnecting;
return Column(
// Connect is only available from a fully disconnected state
// scanning, connecting, or an active session must settle first.
final isButtonDisabled =
connector.state != MeshCoreConnectionState.disconnected;
return ListView(
padding: const EdgeInsets.only(bottom: 32),
children: [
_buildStatusBar(context, connector),
_buildTransportLinks(context),
// Status header
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Align(
key: ValueKey(connector.state),
alignment: Alignment.centerLeft,
child: _buildStatusChip(context, connector),
),
),
),
// Transport switcher
_buildTransportLinks(context),
// Connection form
const SectionHeader('TCP / IP'),
MeshCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -113,7 +133,6 @@ class _TcpScreenState extends State<TcpScreen> {
decoration: InputDecoration(
labelText: context.l10n.tcpHostLabel,
hintText: context.l10n.tcpHostHint,
border: const OutlineInputBorder(),
),
enabled: !isConnecting,
keyboardType: TextInputType.url,
@@ -124,7 +143,6 @@ class _TcpScreenState extends State<TcpScreen> {
decoration: InputDecoration(
labelText: context.l10n.tcpPortLabel,
hintText: context.l10n.tcpPortHint,
border: const OutlineInputBorder(),
),
enabled: !isConnecting,
keyboardType: TextInputType.number,
@@ -132,7 +150,12 @@ class _TcpScreenState extends State<TcpScreen> {
const SizedBox(height: 16),
FilledButton.icon(
key: const Key('tcp_connect_button'),
onPressed: isButtonDisabled ? null : _connectTcp,
onPressed: isButtonDisabled
? null
: () {
HapticFeedback.lightImpact();
_connectTcp();
},
icon: isConnecting
? const SizedBox(
width: 18,
@@ -151,6 +174,39 @@ class _TcpScreenState extends State<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) {
return Padding(
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 {
if (_connector.state == MeshCoreConnectionState.connecting ||
_connector.state == MeshCoreConnectionState.connected ||
+39 -54
View File
@@ -19,6 +19,8 @@ import '../utils/battery_utils.dart';
import '../helpers/snack_bar_builder.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/telemetry_location_map.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
class TelemetryScreen extends StatefulWidget {
final Contact contact;
@@ -319,6 +321,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final connector = context.watch<MeshCoreConnector>();
final settings = context.watch<AppSettingsService>().settings;
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
@@ -387,7 +390,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
l10n.telemetry_noData,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
color: scheme.onSurfaceVariant,
),
),
),
@@ -415,34 +418,21 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
int channel,
bool isImperialUnits,
) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
for (final entry in channelData.entries)
_buildTelemetryField(entry, channel, isImperialUnits),
],
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionHeader(title, padding: const EdgeInsets.fromLTRB(16, 16, 16, 8)),
MeshCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final entry in channelData.entries)
_buildTelemetryField(entry, channel, isImperialUnits),
],
),
),
),
],
);
}
@@ -601,30 +591,19 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
final l10n = context.l10n;
final counterText = _autoRefreshCounterText();
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.autorenew,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.common_autoRefresh,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
_buildAutoRefreshNumberField(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionHeader(
l10n.common_autoRefresh,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
),
MeshCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildAutoRefreshNumberField(
controller: _autoRefreshIntervalController,
label: l10n.common_interval,
min: _autoRefreshMinIntervalSeconds,
@@ -684,6 +663,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
],
),
),
],
);
}
@@ -913,6 +893,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
}
Widget _buildInfoRow(String label, String value) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
@@ -922,7 +903,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
child: Text(
label,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
color: scheme.onSurfaceVariant,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
@@ -930,7 +912,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
const SizedBox(width: 8),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
style: MeshTheme.mono(
fontSize: 13,
color: scheme.onSurface,
),
textAlign: TextAlign.end,
),
],
+119 -103
View File
@@ -6,10 +6,13 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
import '../utils/app_logger.dart';
import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/empty_state.dart';
import '../widgets/mesh_ui.dart';
import '../helpers/snack_bar_builder.dart';
import 'channels_screen.dart';
import 'tcp_screen.dart';
@@ -97,9 +100,27 @@ class _UsbScreenState extends State<UsbScreen> {
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatusBar(context, connector),
// Status header
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Align(
key: ValueKey(
'${connector.state}_$_isLoadingPorts',
),
alignment: Alignment.centerLeft,
child: _buildStatusChip(context, connector),
),
),
),
// Transport switcher
_buildTransportLinks(context),
// Port list
Expanded(child: _buildPortList(context, connector)),
],
);
@@ -132,6 +153,52 @@ class _UsbScreenState extends State<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) {
return Padding(
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) {
final l10n = context.l10n;
if (_isLoadingPorts) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.usb,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
l10n.usbStatus_searching,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
return EmptyState(
icon: Icons.usb,
title: l10n.usbStatus_searching,
);
}
if (_ports.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.usb,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
l10n.usbScreenEmptyState,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
return EmptyState(
icon: Icons.usb,
title: l10n.usbScreenEmptyState,
);
}
@@ -265,10 +247,9 @@ class _UsbScreenState extends State<UsbScreen> {
connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb;
return ListView.separated(
padding: const EdgeInsets.all(8),
return ListView.builder(
padding: const EdgeInsets.only(bottom: 32),
itemCount: _ports.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final port = _ports[index];
final displayName = friendlyUsbPortName(port);
@@ -276,15 +257,50 @@ class _UsbScreenState extends State<UsbScreen> {
final showRawName =
rawName != displayName && !rawName.startsWith('web:');
return ListTile(
leading: const Icon(Icons.usb),
title: Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
return ListEntrance(
index: index,
child: MeshCard(
padding: EdgeInsets.zero,
child: ListTile(
onTap: isConnecting
? null
: () {
HapticFeedback.selectionClick();
_connectPort(port);
},
leading: AvatarCircle(
name: displayName,
size: 40,
icon: Icons.usb,
color: Theme.of(context).colorScheme.primary,
),
title: Text(
displayName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: showRawName
? Text(
rawName,
style: MeshTheme.mono(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: Icon(
Icons.chevron_right,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
subtitle: showRawName ? Text(rawName) : null,
trailing: const Icon(Icons.chevron_right),
onTap: isConnecting ? null : () => _connectPort(port),
);
},
);
+210 -48
View File
@@ -1,47 +1,48 @@
import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder;
import 'package:flutter/material.dart';
/// MeshCore palette cool slate dark theme with sky-blue accents.
/// MeshCore palette high-contrast slate surfaces with sky-blue accents.
class MeshPalette {
MeshPalette._();
// Surfaces (cool near-black, slate undertone)
static const bg = Color(0xFF101417);
static const bg1 = Color(0xFF161B1F);
static const bg2 = Color(0xFF1D242A);
static const bg3 = Color(0xFF28313A);
static const bg4 = Color(0xFF344049);
// Surfaces shared with the map overlays and navigation.
static const bg = Color(0xFF0B1220);
static const bg1 = Color(0xFF0F172A);
static const bg2 = Color(0xFF162033);
static const bg3 = Color(0xFF1E293B);
static const bg4 = Color(0xFF334155);
// Lines
static const line = Color(0xFF222B31);
static const line2 = Color(0xFF344049);
static const line3 = Color(0xFF485762);
static const line = Color(0xFF1E293B);
static const line2 = Color(0xFF334155);
static const line3 = Color(0xFF475569);
// Ink
static const ink = Color(0xFFE9EEF3);
static const ink2 = Color(0xFFB5C0C9);
static const ink3 = Color(0xFF7C8A95);
static const ink4 = Color(0xFF556470);
static const ink = Color(0xFFF8FAFC);
static const ink2 = Color(0xFFCBD5E1);
static const ink3 = Color(0xFF94A3B8);
static const ink4 = Color(0xFF64748B);
// Signal-quality green (used only for SNR coloring, not UI chrome)
static const signal = Color(0xFF7BEFA8);
static const signalDim = Color(0xFF4DC580);
static const signal = Color(0xFF22C55E);
static const signalDim = Color(0xFF16A34A);
// Warn (ember)
static const warn = Color(0xFFFFA552);
static const warnDim = Color(0xFFC27E3C);
static const warnBg = Color(0x1CFFA552);
static const warnLine = Color(0x4DFFA552);
// Warn
static const warn = Color(0xFFF59E0B);
static const warnDim = Color(0xFFD97706);
static const warnBg = Color(0x1FF59E0B);
static const warnLine = Color(0x66F59E0B);
// Alert (coral)
static const alert = Color(0xFFFF6A5C);
static const alertBg = Color(0x1CFF6A5C);
static const alertLine = Color(0x52FF6A5C);
// Alert
static const alert = Color(0xFFEF4444);
static const alertBg = Color(0x1FEF4444);
static const alertLine = Color(0x66EF4444);
// Blue (sky) primary accent
static const blue = Color(0xFF7FCBF5);
static const blueDim = Color(0xFF4A9CC9);
static const blueBg = Color(0x1C7FCBF5);
static const blueLine = Color(0x477FCBF5);
// Blue primary map/app accent
static const blue = Color(0xFF0EA5E9);
static const blueDim = Color(0xFF0284C7);
static const blueBg = Color(0x290EA5E9);
static const blueLine = Color(0x800EA5E9);
// Magenta
static const magenta = Color(0xFFDE7FDB);
@@ -49,9 +50,9 @@ class MeshPalette {
static const magentaLine = Color(0x47DE7FDB);
// Me bubble (dusk blue)
static const me = Color(0xFF1B2C3D);
static const meBorder = Color(0xFF2C4A66);
static const meInk = Color(0xFFDCE9F5);
static const me = Color(0xFF0C4A6E);
static const meBorder = Color(0xFF0369A1);
static const meInk = Color(0xFFF0F9FF);
// Light variant (used when user explicitly picks light theme)
static const lightBg = Color(0xFFF4F6F8);
@@ -64,6 +65,51 @@ class MeshPalette {
static const lightBlue = Color(0xFF2F6EA8);
}
/// High-contrast semantic colors for UI rendered over variable map tiles.
class MapPalette {
MapPalette._();
static const online = Color(0xFF22C55E);
static const offline = Color(0xFF6B7280);
static const stale = Color(0xFFF59E0B);
static const repeater = Color(0xFF2563EB);
static const router = Color(0xFF7C3AED);
static const batteryLow = Color(0xFFEF4444);
static const cluster = Color(0xFFF97316);
static const selected = Color(0xFF0EA5E9);
static const sensor = Color(0xFF0F766E);
static const shared = Color(0xFF0369A1);
static const panelLight = Color(0xF0FFFFFF);
static const panelDark = Color(0xF50B1220);
static const textPrimary = Color(0xFFF8FAFC);
static const textSecondary = Color(0xFFCBD5E1);
static const textMuted = Color(0xFF94A3B8);
static const border = Color(0x5264758B);
static const markerOutline = Colors.white;
static const markerShadow = Color(0xB3000000);
}
/// High-contrast colors for line-of-sight maps and elevation profiles.
class LosPalette {
LosPalette._();
static const terrain = Color(0xFFA3E635);
static const beam = Color(0xFF38BDF8);
static const horizon = Color(0xFFFBBF24);
static const blocked = Color(0xFFEF4444);
static const marginal = Color(0xFFF59E0B);
static const clear = Color(0xFF22C55E);
static const selected = Color(0xFF0EA5E9);
static const chartBackground = Color(0xFF0B1220);
static const panelDark = Color(0xF00F172A);
static const panelLight = Color(0xF5FFFFFF);
static const text = Color(0xFFF8FAFC);
static const textMuted = Color(0xFFCBD5E1);
static const border = Color(0x5264758B);
static const shadow = Color(0x99000000);
}
/// Named font stacks Flutter falls back to system fonts when the named
/// family isn't installed, keeping things working without bundled assets.
class MeshFonts {
@@ -72,6 +118,7 @@ class MeshFonts {
static const sans = 'Inter';
static const mono = 'JetBrains Mono';
static const display = 'Instrument Serif';
static const emoji = 'Noto Color Emoji';
static const List<String> sansFallback = [
'system-ui',
@@ -93,6 +140,11 @@ class MeshFonts {
'Times New Roman',
'serif',
];
static const List<String> emojiFallback = [
'Apple Color Emoji',
'Segoe UI Emoji',
'Noto Emoji',
];
}
/// Radii used consistently across the app.
@@ -113,21 +165,21 @@ class MeshTheme {
static ThemeData dark() {
const scheme = ColorScheme.dark(
primary: MeshPalette.blue,
onPrimary: Color(0xFF0A1A26),
primaryContainer: MeshPalette.blueBg,
onPrimaryContainer: MeshPalette.blue,
onPrimary: Colors.white,
primaryContainer: Color(0xFF075985),
onPrimaryContainer: Colors.white,
secondary: MeshPalette.magenta,
onSecondary: Color(0xFF201020),
onSecondary: Colors.white,
secondaryContainer: Color(0xFF331A33),
onSecondaryContainer: MeshPalette.magenta,
onSecondaryContainer: Colors.white,
tertiary: MeshPalette.warn,
onTertiary: Color(0xFF1F1206),
tertiaryContainer: Color(0xFF3A2710),
onTertiaryContainer: Color(0xFFFFC58A),
onTertiary: Color(0xFF0B1220),
tertiaryContainer: Color(0xFF78350F),
onTertiaryContainer: Colors.white,
error: MeshPalette.alert,
onError: Color(0xFF1A0A08),
errorContainer: MeshPalette.alertBg,
onErrorContainer: MeshPalette.alert,
onError: Colors.white,
errorContainer: Color(0xFF7F1D1D),
onErrorContainer: Colors.white,
surface: MeshPalette.bg,
onSurface: MeshPalette.ink,
surfaceContainerLowest: MeshPalette.bg,
@@ -334,9 +386,9 @@ class MeshTheme {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surfaceContainerLow,
backgroundColor: scheme.surface,
surfaceTintColor: Colors.transparent,
indicatorColor: scheme.primary.withValues(alpha: 0.14),
indicatorColor: scheme.primary,
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.md),
),
@@ -348,13 +400,13 @@ class MeshTheme {
fontSize: 10,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
letterSpacing: 0.1,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return IconThemeData(
color: selected ? scheme.primary : scheme.onSurfaceVariant,
color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
size: 22,
);
}),
@@ -393,6 +445,106 @@ class MeshTheme {
),
iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 22),
splashFactory: InkSparkle.splashFactory,
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: FadeForwardsPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.linux: FadeForwardsPageTransitionsBuilder(),
TargetPlatform.macOS: FadeForwardsPageTransitionsBuilder(),
TargetPlatform.windows: FadeForwardsPageTransitionsBuilder(),
},
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom(
selectedBackgroundColor: scheme.primary.withValues(alpha: 0.16),
selectedForegroundColor: scheme.primary,
side: BorderSide(color: scheme.outlineVariant),
textStyle: const TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? scheme.onPrimary
: scheme.onSurfaceVariant,
),
trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? scheme.primary
: scheme.surfaceContainerHighest,
),
trackOutlineColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? Colors.transparent
: scheme.outline,
),
),
sliderTheme: SliderThemeData(
activeTrackColor: scheme.primary,
inactiveTrackColor: scheme.surfaceContainerHighest,
thumbColor: scheme.primary,
overlayColor: scheme.primary.withValues(alpha: 0.12),
valueIndicatorColor: scheme.surfaceContainerHighest,
valueIndicatorTextStyle: TextStyle(
fontFamily: MeshFonts.mono,
fontFamilyFallback: MeshFonts.monoFallback,
color: scheme.onSurface,
fontSize: 12,
),
trackHeight: 3,
),
tabBarTheme: TabBarThemeData(
labelColor: scheme.primary,
unselectedLabelColor: scheme.onSurfaceVariant,
indicatorColor: scheme.primary,
dividerColor: scheme.outlineVariant,
labelStyle: const TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontSize: 13.5,
fontWeight: FontWeight.w700,
),
unselectedLabelStyle: const TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontSize: 13.5,
fontWeight: FontWeight.w500,
),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
color: scheme.primary,
linearTrackColor: scheme.surfaceContainerHigh,
circularTrackColor: Colors.transparent,
),
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(MeshRadii.sm),
border: Border.all(color: scheme.outline),
),
textStyle: TextStyle(color: scheme.onSurface, fontSize: 12),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(MeshRadii.pill),
),
textStyle: const TextStyle(
fontFamily: MeshFonts.sans,
fontFamilyFallback: MeshFonts.sansFallback,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
);
}
@@ -443,6 +595,16 @@ class MeshTheme {
);
}
/// Color-emoji style with platform fallbacks and stable vertical metrics.
static TextStyle emoji({double fontSize = 28}) {
return TextStyle(
fontFamily: MeshFonts.emoji,
fontFamilyFallback: MeshFonts.emojiFallback,
fontSize: fontSize,
height: 1,
);
}
/// Color-code an SNR value for consistency across the app.
static Color snrColor(num? snr, {required bool blocked}) {
if (blocked) return MeshPalette.alert;
+12 -11
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../connector/meshcore_connector.dart';
import '../theme/mesh_theme.dart';
class BatteryUi {
final IconData icon;
@@ -10,19 +11,19 @@ class BatteryUi {
BatteryUi batteryUiForPercent(int? percent) {
if (percent == null) {
return const BatteryUi(Icons.battery_unknown, Colors.grey);
return const BatteryUi(Icons.battery_unknown, null);
}
final p = percent.clamp(0, 100);
return switch (p) {
<= 5 => const BatteryUi(Icons.battery_alert, Colors.redAccent),
<= 15 => const BatteryUi(Icons.battery_0_bar, Colors.redAccent),
<= 30 => const BatteryUi(Icons.battery_1_bar, Colors.orange),
<= 45 => const BatteryUi(Icons.battery_2_bar, Colors.amber),
<= 60 => const BatteryUi(Icons.battery_3_bar, Colors.lightGreen),
<= 80 => const BatteryUi(Icons.battery_5_bar, Colors.green),
_ => const BatteryUi(Icons.battery_full, Colors.green),
<= 5 => const BatteryUi(Icons.battery_alert, MeshPalette.alert),
<= 15 => const BatteryUi(Icons.battery_0_bar, MeshPalette.alert),
<= 30 => const BatteryUi(Icons.battery_1_bar, MeshPalette.warn),
<= 45 => const BatteryUi(Icons.battery_2_bar, MeshPalette.warn),
<= 60 => const BatteryUi(Icons.battery_3_bar, null),
<= 80 => const BatteryUi(Icons.battery_5_bar, null),
_ => const BatteryUi(Icons.battery_full, MeshPalette.signal),
};
}
@@ -76,9 +77,9 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
Flexible(
child: Text(
displayText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w600,
color: batteryUi.color,
),
maxLines: 1,
+82 -30
View File
@@ -1,9 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
import 'mesh_ui.dart';
import 'signal_ui.dart';
/// A reusable tile widget for displaying a MeshCore device in a list
/// A MeshCard-based row for displaying a scanned BLE device.
/// Shows an AvatarCircle (router icon, deterministic hue from device name),
/// device name, mono MAC address, mono RSSI dBm, and SignalBars on the right.
/// While connecting, shows a small progress ring instead of signal bars.
class DeviceTile extends StatelessWidget {
final ScanResult scanResult;
final VoidCallback? onTap;
@@ -23,27 +30,10 @@ class DeviceTile extends StatelessWidget {
final name = device.platformName.isNotEmpty
? device.platformName
: scanResult.advertisementData.advName;
final displayName = name.isNotEmpty ? name : context.l10n.common_unknownDevice;
final mac = device.remoteId.toString();
final scheme = Theme.of(context).colorScheme;
return ListTile(
enabled: onTap != null || isConnecting,
leading: _buildSignalIcon(rssi),
title: Text(
name.isNotEmpty ? name : context.l10n.common_unknownDevice,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(device.remoteId.toString()),
trailing: isConnecting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
onTap: onTap,
);
}
Widget _buildSignalIcon(int rssi) {
final tier = rssi >= -60
? 0
: rssi >= -70
@@ -55,15 +45,77 @@ class DeviceTile extends StatelessWidget {
: 4;
final signalUi = signalUiForStrengthTier(tier);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(signalUi.icon, color: signalUi.color),
Text(
'$rssi dBm',
style: TextStyle(fontSize: 10, color: signalUi.color),
),
],
return MeshCard(
onTap: onTap == null
? null
: () {
HapticFeedback.selectionClick();
onTap!();
},
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
AvatarCircle(
name: displayName,
size: 42,
icon: Icons.router,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: scheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
mac,
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
if (isConnecting)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.primary,
),
)
else
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(signalUi.icon, size: 16, color: signalUi.color),
const SizedBox(height: 3),
Text(
'$rssi dBm',
style: MeshTheme.mono(
fontSize: 10,
color: signalUi.color,
),
),
],
),
],
),
);
}
}
+60 -23
View File
@@ -29,31 +29,68 @@ class FeatureToggleRow extends StatefulWidget {
class _FeatureToggleRow extends State<FeatureToggleRow> {
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: SwitchListTile(
title: Text(widget.title),
subtitle: Text(widget.subtitle),
value: widget.value,
onChanged: widget.onChanged,
contentPadding: EdgeInsets.zero,
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
widget.subtitle,
style: textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
),
if (widget.hasRefreshing)
IconButton(
icon: widget.isRefreshing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 20),
onPressed: widget.isRefreshing ? null : widget.onRefresh,
tooltip: widget.refreshTooltip,
visualDensity: VisualDensity.compact,
const SizedBox(width: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Switch(
value: widget.value,
onChanged: widget.onChanged,
),
if (widget.hasRefreshing) ...[
const SizedBox(width: 4),
widget.isRefreshing
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 1.8,
color: scheme.primary,
),
)
: IconButton(
icon: const Icon(Icons.refresh, size: 18),
onPressed: widget.onRefresh,
tooltip: widget.refreshTooltip,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
],
],
),
],
],
),
);
}
}
+12 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
class EmojiPicker extends StatelessWidget {
final Function(String) onEmojiSelected;
@@ -257,7 +258,11 @@ class EmojiPicker extends StatelessWidget {
),
child: Text(
emoji,
style: const TextStyle(fontSize: 28),
style: MeshTheme.emoji(),
textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
),
),
),
@@ -298,7 +303,12 @@ class EmojiPicker extends StatelessWidget {
child: Center(
child: Text(
emojis[index],
style: const TextStyle(fontSize: 28),
style: MeshTheme.emoji(),
textHeightBehavior:
const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
),
),
),
+91 -21
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
/// A centered empty state display with icon, title, and optional subtitle/action.
class EmptyState extends StatelessWidget {
/// Features a tinted icon circle, fade+slide entrance animation, and clear
/// typography hierarchy using the MeshCore design system.
class EmptyState extends StatefulWidget {
final IconData icon;
final String title;
final String? subtitle;
@@ -15,29 +17,97 @@ class EmptyState extends StatelessWidget {
this.action,
});
@override
State<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
Widget build(BuildContext context) {
final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: onSurfaceVariant.withValues(alpha: 0.6)),
const SizedBox(height: 16),
Text(title, style: TextStyle(fontSize: 16, color: onSurfaceVariant)),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: TextStyle(
fontSize: 14,
color: onSurfaceVariant.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
final scheme = Theme.of(context).colorScheme;
return FadeTransition(
opacity: _curve,
child: SlideTransition(
position: Tween(
begin: const Offset(0, 0.06),
end: Offset.zero,
).animate(_curve),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.primary.withValues(alpha: 0.08),
border: Border.all(
color: scheme.primary.withValues(alpha: 0.18),
width: 1.5,
),
),
child: Icon(
widget.icon,
size: 36,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: scheme.onSurface,
letterSpacing: -0.1,
),
textAlign: TextAlign.center,
),
if (widget.subtitle != null) ...[
const SizedBox(height: 8),
Text(
widget.subtitle!,
style: TextStyle(
fontSize: 13.5,
color: scheme.onSurfaceVariant,
height: 1.45,
),
textAlign: TextAlign.center,
),
],
if (widget.action != null) ...[
const SizedBox(height: 28),
widget.action!,
],
],
),
],
if (action != null) ...[const SizedBox(height: 24), action!],
],
),
),
),
);
}
+33 -3
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../helpers/chat_scroll_controller.dart';
import '../theme/mesh_theme.dart';
class JumpToBottomButton extends StatelessWidget {
final ChatScrollController scrollController;
@@ -8,6 +10,7 @@ class JumpToBottomButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ValueListenableBuilder<bool>(
valueListenable: scrollController.showJumpToBottom,
builder: (context, show, _) {
@@ -15,9 +18,36 @@ class JumpToBottomButton extends StatelessWidget {
return Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton.small(
onPressed: scrollController.jumpToBottom,
child: const Icon(Icons.keyboard_arrow_down),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: scrollController.jumpToBottom,
borderRadius: BorderRadius.circular(MeshRadii.pill),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.surfaceContainerHigh.withValues(alpha: 0.92),
border: Border.all(
color: scheme.outlineVariant,
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(
Icons.keyboard_arrow_down,
size: 22,
color: scheme.primary,
),
),
),
),
);
},
+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 '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
class MessageStatusIcon extends StatefulWidget {
final bool isAcked;
@@ -71,7 +72,11 @@ class _MessageStatusIconState extends State<MessageStatusIcon>
if (widget.isFailed) {
return Semantics(
label: l10n.messageStatus_failed,
child: Icon(Icons.cancel, size: size, color: colorScheme.error),
child: Icon(
Icons.cancel,
size: size,
color: colorScheme.error,
),
);
}
@@ -92,7 +97,10 @@ class _MessageStatusIconState extends State<MessageStatusIcon>
: widget.isAcked
? l10n.messageStatus_delivered
: l10n.messageStatus_sent;
final Color color = delivered ? colorScheme.tertiary : baseColor;
// Use palette colors: tertiary (warn/amber) for acked/repeated, base for sent.
final Color color = delivered
? MeshPalette.signal.withValues(alpha: 0.9)
: baseColor;
return Semantics(
label: label,
+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 '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
class QuickSwitchBar extends StatelessWidget {
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final int contactsUnreadCount;
final int channelsUnreadCount;
final bool highContrast;
const QuickSwitchBar({
super.key,
@@ -15,6 +17,7 @@ class QuickSwitchBar extends StatelessWidget {
required this.onDestinationSelected,
this.contactsUnreadCount = 0,
this.channelsUnreadCount = 0,
this.highContrast = false,
});
@override
@@ -22,6 +25,14 @@ class QuickSwitchBar extends StatelessWidget {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final labelStyle = theme.textTheme.labelMedium ?? const TextStyle();
final background = highContrast ? MapPalette.panelDark : Colors.transparent;
final selectedColor = highContrast
? MapPalette.textPrimary
: colorScheme.onPrimary;
final unselectedColor = highContrast
? MapPalette.textSecondary
: colorScheme.onSurfaceVariant;
final indicator = highContrast ? MapPalette.selected : colorScheme.primary;
return SizedBox(
width: double.infinity,
@@ -31,9 +42,11 @@ class QuickSwitchBar extends StatelessWidget {
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.transparent,
color: background,
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
color: highContrast
? MapPalette.border
: colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
child: NavigationBarTheme(
@@ -41,22 +54,18 @@ class QuickSwitchBar extends StatelessWidget {
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
indicatorColor: colorScheme.primaryContainer,
indicatorColor: indicator,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final isSelected = states.contains(WidgetState.selected);
return labelStyle.copyWith(
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
color: isSelected ? selectedColor : unselectedColor,
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final isSelected = states.contains(WidgetState.selected);
return IconThemeData(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
color: isSelected ? selectedColor : unselectedColor,
);
}),
),
+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:provider/provider.dart';
import '../theme/mesh_theme.dart';
import 'mesh_ui.dart';
void pushCompanionRadioStatsScreen(BuildContext context) {
Navigator.push(
context,
@@ -140,12 +143,12 @@ class AirActivityDotState extends State<AirActivityDot> {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final on = widget.active && _blink;
return Icon(
Icons.circle,
size: 12,
color: on ? scheme.primary : scheme.outline,
final scheme = Theme.of(context).colorScheme;
return PulseDot(
color: on ? MeshPalette.blue : scheme.outline,
size: 11,
animate: false,
);
}
}
+30 -15
View File
@@ -9,6 +9,8 @@ import '../l10n/contact_localization.dart';
import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import '../utils/app_logger.dart';
import 'routing_sheet.dart';
@@ -269,26 +271,40 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return AlertDialog(
title: Row(
children: [
Icon(Icons.cell_tower, color: Theme.of(context).colorScheme.tertiary),
const SizedBox(width: 8),
AvatarCircle(
name: repeater.name,
size: 40,
color: MeshPalette.warn,
icon: Icons.cell_tower,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.login_repeaterLogin),
Text(
l10n.login_repeaterLogin,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
Text(
repeater.name,
style: TextStyle(
fontSize: 14,
fontSize: 13,
fontWeight: FontWeight.normal,
color: Theme.of(context).colorScheme.onSurfaceVariant,
color: scheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
@@ -319,14 +335,14 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
Icon(
Icons.error,
size: 18,
color: Theme.of(context).colorScheme.error,
color: scheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_loginError!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
color: scheme.error,
fontSize: 13,
),
),
@@ -341,7 +357,6 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
decoration: InputDecoration(
labelText: l10n.login_password,
hintText: l10n.login_enterPassword,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
@@ -390,9 +405,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
children: [
Text(
l10n.login_routing,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
style: MeshTheme.accentLabel(
color: scheme.onSurfaceVariant,
fontSize: 11,
),
),
const Spacer(),
@@ -421,7 +436,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
? scheme.primary
: null,
),
const SizedBox(width: 8),
@@ -444,7 +459,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
? scheme.primary
: null,
),
const SizedBox(width: 8),
@@ -468,7 +483,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
repeater.pathLabel(context.l10n),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
@@ -502,7 +517,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
color: scheme.onPrimary,
),
),
const SizedBox(width: 12),
+28 -13
View File
@@ -10,6 +10,8 @@ import '../l10n/contact_localization.dart';
import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import '../utils/app_logger.dart';
import '../helpers/snack_bar_builder.dart';
import 'routing_sheet.dart';
@@ -226,26 +228,40 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return AlertDialog(
title: Row(
children: [
Icon(Icons.group, color: Theme.of(context).colorScheme.secondary),
const SizedBox(width: 8),
AvatarCircle(
name: repeater.name,
size: 40,
color: MeshPalette.magenta,
icon: Icons.group,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.login_roomLogin),
Text(
l10n.login_roomLogin,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
Text(
repeater.name,
style: TextStyle(
fontSize: 14,
fontSize: 13,
fontWeight: FontWeight.normal,
color: Theme.of(context).colorScheme.onSurfaceVariant,
color: scheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
@@ -275,7 +291,6 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
decoration: InputDecoration(
labelText: l10n.login_password,
hintText: l10n.login_enterPassword,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
@@ -319,9 +334,9 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
children: [
Text(
l10n.login_routing,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
style: MeshTheme.accentLabel(
color: scheme.onSurfaceVariant,
fontSize: 11,
),
),
const Spacer(),
@@ -350,7 +365,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
? scheme.primary
: null,
),
const SizedBox(width: 8),
@@ -373,7 +388,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
? scheme.primary
: null,
),
const SizedBox(width: 8),
@@ -397,7 +412,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
repeater.pathLabel(context.l10n),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
@@ -431,7 +446,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
color: scheme.onPrimary,
),
),
const SizedBox(width: 12),
+6 -5
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../theme/mesh_theme.dart';
class SignalUi {
final IconData icon;
@@ -12,27 +13,27 @@ SignalUi signalUiForStrengthTier(int tier) {
case 0:
return const SignalUi(
icon: Icons.signal_cellular_4_bar,
color: Colors.green,
color: MeshPalette.signal,
);
case 1:
return const SignalUi(
icon: Icons.signal_cellular_alt,
color: Colors.lightGreen,
color: MeshPalette.signalDim,
);
case 2:
return const SignalUi(
icon: Icons.signal_cellular_alt_2_bar,
color: Colors.amber,
color: MeshPalette.warn,
);
case 3:
return const SignalUi(
icon: Icons.signal_cellular_alt_1_bar,
color: Colors.orange,
color: MeshPalette.warnDim,
);
default:
return const SignalUi(
icon: Icons.signal_cellular_alt_1_bar,
color: Colors.red,
color: MeshPalette.alert,
);
}
}
+40 -17
View File
@@ -5,6 +5,8 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../theme/mesh_theme.dart';
import 'mesh_ui.dart';
import 'signal_ui.dart';
Contact? _getRepeaterPrefixMatchNearLocation(
@@ -218,10 +220,6 @@ class _SNRIndicatorState extends State<SNRIndicator> {
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final repeater = directBestRepeaters[index];
final snrUi = snrUiFromSNR(
repeater.snr,
widget.connector.currentSf,
);
final allContacts = widget.connector.allContacts;
final selfLat = widget.connector.selfLatitude;
@@ -242,22 +240,47 @@ class _SNRIndicatorState extends State<SNRIndicator> {
);
final name = contact?.name;
final hex = repeater.pubkeyFirstByte
.toRadixString(16)
.padLeft(2, '0');
final snrColor = MeshTheme.snrColor(
repeater.snr,
blocked: false,
);
return Column(
children: [
ListTile(
leading: Icon(snrUi.icon, color: snrUi.color),
title: Text(
name ??
repeater.pubkeyFirstByte
.toRadixString(16)
.padLeft(2, '0'),
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Row(
children: [
AvatarCircle(
name: name ?? hex,
size: 36,
color: snrColor,
),
subtitle: Text(
'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}',
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name ?? hex,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
'${repeater.snr.toStringAsFixed(1)} dB • ${_formatLastUpdated(repeater.lastUpdated)}',
style: MeshTheme.mono(
fontSize: 11,
color: snrColor,
),
),
],
),
),
),
],
],
),
);
},
),
+2 -7
View File
@@ -11,6 +11,7 @@ import '../models/app_settings.dart';
import '../models/contact.dart';
import '../services/app_settings_service.dart';
import '../services/map_tile_cache_service.dart';
import 'themed_map_tile_layer.dart';
class TelemetryLocationMap extends StatefulWidget {
final double latitude;
@@ -114,13 +115,7 @@ class _TelemetryLocationMapState extends State<TelemetryLocationMap> {
),
),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
ThemedMapTileLayer(tileCache: tileCache),
MarkerLayer(
markers: [
...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 '../theme/mesh_theme.dart';
class UnreadBadge extends StatelessWidget {
final int count;
@@ -9,17 +11,18 @@ class UnreadBadge extends StatelessWidget {
Widget build(BuildContext context) {
final display = count > 9999 ? '9999+' : count.toString();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: Colors.redAccent,
borderRadius: BorderRadius.circular(10),
color: MeshPalette.blue.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(MeshRadii.pill),
border: Border.all(color: MeshPalette.blue.withValues(alpha: 0.45)),
),
child: Text(
display,
style: const TextStyle(
color: Colors.white,
style: MeshTheme.mono(
fontSize: 11,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
color: MeshPalette.blue,
),
),
);
+28 -9
View File
@@ -1,30 +1,49 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
import '../theme/mesh_theme.dart';
class UnreadDivider extends StatelessWidget {
const UnreadDivider({super.key});
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
final scheme = Theme.of(context).colorScheme;
final color = scheme.primary;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Expanded(child: Divider(color: color)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
Expanded(
child: Container(
height: 1,
color: color.withValues(alpha: 0.25),
),
),
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(MeshRadii.pill),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Text(
context.l10n.chat_newMessages,
style: TextStyle(
style: MeshTheme.mono(
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
Expanded(child: Divider(color: color)),
const SizedBox(width: 10),
Expanded(
child: Container(
height: 1,
color: color.withValues(alpha: 0.25),
),
),
],
),
);