mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-14 22:55:12 +10:00
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:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1448
-1043
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
+545
-449
File diff suppressed because it is too large
Load Diff
+243
-170
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+885
-212
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+538
-394
File diff suppressed because it is too large
Load Diff
+100
-56
@@ -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 ||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user