mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-31 23:56:02 +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,
|
DismissDirection? dismissDirection,
|
||||||
Clip? clipBehavior,
|
Clip? clipBehavior,
|
||||||
}) {
|
}) {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
// Callers often reach here after an async gap; the context may already be
|
||||||
|
// unmounted, or deactivated (popped but not yet disposed) — ancestor
|
||||||
|
// lookups on a deactivated element throw. Showing nothing is the right
|
||||||
|
// outcome in both cases.
|
||||||
|
if (!context.mounted) return;
|
||||||
|
var isActive = true;
|
||||||
|
assert(() {
|
||||||
|
isActive = (context as Element).debugIsActive;
|
||||||
|
return true;
|
||||||
|
}());
|
||||||
|
if (!isActive) return;
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
if (messenger == null) return;
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
key: key,
|
key: key,
|
||||||
|
|||||||
+94
-1
@@ -900,6 +900,17 @@
|
|||||||
},
|
},
|
||||||
"chat_invalidLink": "Invalid link format",
|
"chat_invalidLink": "Invalid link format",
|
||||||
"map_title": "Node Map",
|
"map_title": "Node Map",
|
||||||
|
"map_searchHint": "Search node name or ID",
|
||||||
|
"map_activity": "Activity",
|
||||||
|
"map_online": "Online",
|
||||||
|
"map_recent": "Recent",
|
||||||
|
"map_stale": "Stale",
|
||||||
|
"map_visible": "Visible",
|
||||||
|
"map_hidden": "Hidden",
|
||||||
|
"map_centerOnNode": "Center on node",
|
||||||
|
"map_details": "Details",
|
||||||
|
"map_noGps": "No GPS",
|
||||||
|
"map_noResults": "No matching nodes",
|
||||||
"map_lineOfSight": "Line of Sight",
|
"map_lineOfSight": "Line of Sight",
|
||||||
"map_losScreenTitle": "Line of Sight",
|
"map_losScreenTitle": "Line of Sight",
|
||||||
"map_noNodesWithLocation": "No nodes with location data",
|
"map_noNodesWithLocation": "No nodes with location data",
|
||||||
@@ -2501,5 +2512,87 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pathTrace_legendGpsConfirmed": "GPS confirmed",
|
"pathTrace_legendGpsConfirmed": "GPS confirmed",
|
||||||
"pathTrace_legendInferred": "Inferred position"
|
"pathTrace_legendInferred": "Inferred position",
|
||||||
|
"pathMap_viewSingle": "Single",
|
||||||
|
"pathMap_viewCombined": "Combined",
|
||||||
|
"pathMap_play": "Play",
|
||||||
|
"pathMap_pause": "Pause",
|
||||||
|
"pathMap_replay": "Replay",
|
||||||
|
"pathMap_stepBack": "Previous hop",
|
||||||
|
"pathMap_stepForward": "Next hop",
|
||||||
|
"pathMap_animationOn": "Show packet animation",
|
||||||
|
"pathMap_animationOff": "Hide packet animation",
|
||||||
|
"pathMap_hopOf": "Hop {current} of {total}",
|
||||||
|
"@pathMap_hopOf": {
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pathMap_observedPaths": "Observed paths: {count}",
|
||||||
|
"@pathMap_observedPaths": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pathMap_primary": "Primary",
|
||||||
|
"pathMap_alternate": "Alt {index}",
|
||||||
|
"@pathMap_alternate": {
|
||||||
|
"placeholders": {
|
||||||
|
"index": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pathMap_hopCount": "{count, plural, =1{1 hop} other{{count} hops}}",
|
||||||
|
"@pathMap_hopCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pathMap_gpsCount": "{confirmed}/{total} GPS",
|
||||||
|
"@pathMap_gpsCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"confirmed": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pathMap_legendShared": "Shared segment",
|
||||||
|
"pathMap_legendEstimated": "Estimated segment",
|
||||||
|
"pathMap_sharedNodeCount": "Used by {count} paths",
|
||||||
|
"@pathMap_sharedNodeCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pathMap_partialAnimation": "{count, plural, =1{1 hop has no location — the shown path is partial} other{{count} hops have no location — the shown path is partial}}",
|
||||||
|
"@pathMap_partialAnimation": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pathMap_showAllPaths": "Show all",
|
||||||
|
"pathMap_hidePath": "Hide path",
|
||||||
|
"pathMap_showPath": "Show path",
|
||||||
|
"pathMap_collapsePanel": "Collapse panel",
|
||||||
|
"pathMap_expandPanel": "Expand panel",
|
||||||
|
"pathMap_noLocation": "No location",
|
||||||
|
"pathMap_followPacket": "Lock view to packet",
|
||||||
|
"pathMap_unfollowPacket": "Unlock view from packet"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3154,6 +3154,72 @@ abstract class AppLocalizations {
|
|||||||
/// **'Node Map'**
|
/// **'Node Map'**
|
||||||
String get map_title;
|
String get map_title;
|
||||||
|
|
||||||
|
/// No description provided for @map_searchHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search node name or ID'**
|
||||||
|
String get map_searchHint;
|
||||||
|
|
||||||
|
/// No description provided for @map_activity.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Activity'**
|
||||||
|
String get map_activity;
|
||||||
|
|
||||||
|
/// No description provided for @map_online.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Online'**
|
||||||
|
String get map_online;
|
||||||
|
|
||||||
|
/// No description provided for @map_recent.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recent'**
|
||||||
|
String get map_recent;
|
||||||
|
|
||||||
|
/// No description provided for @map_stale.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Stale'**
|
||||||
|
String get map_stale;
|
||||||
|
|
||||||
|
/// No description provided for @map_visible.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Visible'**
|
||||||
|
String get map_visible;
|
||||||
|
|
||||||
|
/// No description provided for @map_hidden.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hidden'**
|
||||||
|
String get map_hidden;
|
||||||
|
|
||||||
|
/// No description provided for @map_centerOnNode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Center on node'**
|
||||||
|
String get map_centerOnNode;
|
||||||
|
|
||||||
|
/// No description provided for @map_details.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Details'**
|
||||||
|
String get map_details;
|
||||||
|
|
||||||
|
/// No description provided for @map_noGps.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No GPS'**
|
||||||
|
String get map_noGps;
|
||||||
|
|
||||||
|
/// No description provided for @map_noResults.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No matching nodes'**
|
||||||
|
String get map_noResults;
|
||||||
|
|
||||||
/// No description provided for @map_lineOfSight.
|
/// No description provided for @map_lineOfSight.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -7701,6 +7767,168 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Inferred position'**
|
/// **'Inferred position'**
|
||||||
String get pathTrace_legendInferred;
|
String get pathTrace_legendInferred;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_viewSingle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Single'**
|
||||||
|
String get pathMap_viewSingle;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_viewCombined.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Combined'**
|
||||||
|
String get pathMap_viewCombined;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_play.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Play'**
|
||||||
|
String get pathMap_play;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_pause.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pause'**
|
||||||
|
String get pathMap_pause;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_replay.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Replay'**
|
||||||
|
String get pathMap_replay;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_stepBack.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Previous hop'**
|
||||||
|
String get pathMap_stepBack;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_stepForward.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Next hop'**
|
||||||
|
String get pathMap_stepForward;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_animationOn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show packet animation'**
|
||||||
|
String get pathMap_animationOn;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_animationOff.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hide packet animation'**
|
||||||
|
String get pathMap_animationOff;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_hopOf.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hop {current} of {total}'**
|
||||||
|
String pathMap_hopOf(int current, int total);
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_observedPaths.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Observed paths: {count}'**
|
||||||
|
String pathMap_observedPaths(int count);
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_primary.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Primary'**
|
||||||
|
String get pathMap_primary;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_alternate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Alt {index}'**
|
||||||
|
String pathMap_alternate(int index);
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_hopCount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 hop} other{{count} hops}}'**
|
||||||
|
String pathMap_hopCount(int count);
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_gpsCount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{confirmed}/{total} GPS'**
|
||||||
|
String pathMap_gpsCount(int confirmed, int total);
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_legendShared.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shared segment'**
|
||||||
|
String get pathMap_legendShared;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_legendEstimated.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Estimated segment'**
|
||||||
|
String get pathMap_legendEstimated;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_sharedNodeCount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Used by {count} paths'**
|
||||||
|
String pathMap_sharedNodeCount(int count);
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_partialAnimation.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 hop has no location — the shown path is partial} other{{count} hops have no location — the shown path is partial}}'**
|
||||||
|
String pathMap_partialAnimation(int count);
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_showAllPaths.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show all'**
|
||||||
|
String get pathMap_showAllPaths;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_hidePath.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hide path'**
|
||||||
|
String get pathMap_hidePath;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_showPath.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show path'**
|
||||||
|
String get pathMap_showPath;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_collapsePanel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Collapse panel'**
|
||||||
|
String get pathMap_collapsePanel;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_expandPanel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Expand panel'**
|
||||||
|
String get pathMap_expandPanel;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_noLocation.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No location'**
|
||||||
|
String get pathMap_noLocation;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_followPacket.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lock view to packet'**
|
||||||
|
String get pathMap_followPacket;
|
||||||
|
|
||||||
|
/// No description provided for @pathMap_unfollowPacket.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unlock view from packet'**
|
||||||
|
String get pathMap_unfollowPacket;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -1738,6 +1738,39 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Карта на възлите';
|
String get map_title => 'Карта на възлите';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Линия на видимост';
|
String get map_lineOfSight => 'Линия на видимост';
|
||||||
|
|
||||||
@@ -4479,4 +4512,111 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Извлечена позиция';
|
String get pathTrace_legendInferred => 'Извлечена позиция';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1737,6 +1737,39 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Karte';
|
String get map_title => 'Karte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Sichtlinie';
|
String get map_lineOfSight => 'Sichtlinie';
|
||||||
|
|
||||||
@@ -4500,4 +4533,111 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Abgeleitete Position';
|
String get pathTrace_legendInferred => 'Abgeleitete Position';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1703,6 +1703,39 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Node Map';
|
String get map_title => 'Node Map';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Line of Sight';
|
String get map_lineOfSight => 'Line of Sight';
|
||||||
|
|
||||||
@@ -4405,4 +4438,111 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Inferred position';
|
String get pathTrace_legendInferred => 'Inferred position';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1734,6 +1734,39 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Mapa de Nodos';
|
String get map_title => 'Mapa de Nodos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Línea de visión';
|
String get map_lineOfSight => 'Línea de visión';
|
||||||
|
|
||||||
@@ -4486,4 +4519,111 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Posición inferida';
|
String get pathTrace_legendInferred => 'Posición inferida';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1741,6 +1741,39 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Carte des nœuds';
|
String get map_title => 'Carte des nœuds';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Ligne de vue';
|
String get map_lineOfSight => 'Ligne de vue';
|
||||||
|
|
||||||
@@ -4512,4 +4545,111 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Position déduite';
|
String get pathTrace_legendInferred => 'Position déduite';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1744,6 +1744,39 @@ class AppLocalizationsHu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Grafikus ábrázás';
|
String get map_title => 'Grafikus ábrázás';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Látási vonal';
|
String get map_lineOfSight => 'Látási vonal';
|
||||||
|
|
||||||
@@ -4500,4 +4533,111 @@ class AppLocalizationsHu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Feltehető helyzet';
|
String get pathTrace_legendInferred => 'Feltehető helyzet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1738,6 +1738,39 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Mappa Nodi';
|
String get map_title => 'Mappa Nodi';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Linea di vista';
|
String get map_lineOfSight => 'Linea di vista';
|
||||||
|
|
||||||
@@ -4492,4 +4525,111 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Posizione dedotta';
|
String get pathTrace_legendInferred => 'Posizione dedotta';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1661,6 +1661,39 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'ノードマップ';
|
String get map_title => 'ノードマップ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => '視界';
|
String get map_lineOfSight => '視界';
|
||||||
|
|
||||||
@@ -4257,4 +4290,111 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => '推測される位置';
|
String get pathTrace_legendInferred => '推測される位置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1656,6 +1656,39 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => '노드 매핑';
|
String get map_title => '노드 매핑';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => '시야';
|
String get map_lineOfSight => '시야';
|
||||||
|
|
||||||
@@ -4254,4 +4287,111 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => '추론된 위치';
|
String get pathTrace_legendInferred => '추론된 위치';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1721,6 +1721,39 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Kaart van de knopen';
|
String get map_title => 'Kaart van de knopen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Zichtlijn';
|
String get map_lineOfSight => 'Zichtlijn';
|
||||||
|
|
||||||
@@ -4463,4 +4496,111 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Afgeleide positie';
|
String get pathTrace_legendInferred => 'Afgeleide positie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1750,6 +1750,39 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Mapa węzłów';
|
String get map_title => 'Mapa węzłów';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Linia wzroku';
|
String get map_lineOfSight => 'Linia wzroku';
|
||||||
|
|
||||||
@@ -4501,4 +4534,111 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Wywnioskowana pozycja';
|
String get pathTrace_legendInferred => 'Wywnioskowana pozycja';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1734,6 +1734,39 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Mapa de Nós';
|
String get map_title => 'Mapa de Nós';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Linha de visão';
|
String get map_lineOfSight => 'Linha de visão';
|
||||||
|
|
||||||
@@ -4480,4 +4513,111 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Posição inferida';
|
String get pathTrace_legendInferred => 'Posição inferida';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1739,6 +1739,39 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Карта нод';
|
String get map_title => 'Карта нод';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Линия видимости';
|
String get map_lineOfSight => 'Линия видимости';
|
||||||
|
|
||||||
@@ -4496,4 +4529,111 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Выведенная позиция';
|
String get pathTrace_legendInferred => 'Выведенная позиция';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1726,6 +1726,39 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Mapa uzlov';
|
String get map_title => 'Mapa uzlov';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Úroveň výhľadu';
|
String get map_lineOfSight => 'Úroveň výhľadu';
|
||||||
|
|
||||||
@@ -4462,4 +4495,111 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Odvodená poloha';
|
String get pathTrace_legendInferred => 'Odvodená poloha';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1719,6 +1719,39 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Mapa omrežja';
|
String get map_title => 'Mapa omrežja';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Linija vida';
|
String get map_lineOfSight => 'Linija vida';
|
||||||
|
|
||||||
@@ -4461,4 +4494,111 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Izpeljana lokacija';
|
String get pathTrace_legendInferred => 'Izpeljana lokacija';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1712,6 +1712,39 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Nodkarta';
|
String get map_title => 'Nodkarta';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Synlinje';
|
String get map_lineOfSight => 'Synlinje';
|
||||||
|
|
||||||
@@ -4435,4 +4468,111 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Antagen position';
|
String get pathTrace_legendInferred => 'Antagen position';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1732,6 +1732,39 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => 'Карта вузлів';
|
String get map_title => 'Карта вузлів';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => 'Пряма видимість';
|
String get map_lineOfSight => 'Пряма видимість';
|
||||||
|
|
||||||
@@ -4495,4 +4528,111 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => 'Висновок щодо положення';
|
String get pathTrace_legendInferred => 'Висновок щодо положення';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1634,6 +1634,39 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get map_title => '节点地图';
|
String get map_title => '节点地图';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_searchHint => 'Search node name or ID';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_activity => 'Activity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_online => 'Online';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_recent => 'Recent';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_stale => 'Stale';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_visible => 'Visible';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_hidden => 'Hidden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_centerOnNode => 'Center on node';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_details => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noGps => 'No GPS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get map_noResults => 'No matching nodes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get map_lineOfSight => '视线';
|
String get map_lineOfSight => '视线';
|
||||||
|
|
||||||
@@ -4145,4 +4178,111 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get pathTrace_legendInferred => '推测的位置';
|
String get pathTrace_legendInferred => '推测的位置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_viewCombined => 'Combined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_play => 'Play';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_pause => 'Pause';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_replay => 'Replay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepBack => 'Previous hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_stepForward => 'Next hop';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOn => 'Show packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_animationOff => 'Hide packet animation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopOf(int current, int total) {
|
||||||
|
return 'Hop $current of $total';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_observedPaths(int count) {
|
||||||
|
return 'Observed paths: $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_primary => 'Primary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_alternate(int index) {
|
||||||
|
return 'Alt $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_hopCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops',
|
||||||
|
one: '1 hop',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_gpsCount(int confirmed, int total) {
|
||||||
|
return '$confirmed/$total GPS';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendShared => 'Shared segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_legendEstimated => 'Estimated segment';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_sharedNodeCount(int count) {
|
||||||
|
return 'Used by $count paths';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pathMap_partialAnimation(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hops have no location — the shown path is partial',
|
||||||
|
one: '1 hop has no location — the shown path is partial',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showAllPaths => 'Show all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_hidePath => 'Hide path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_showPath => 'Show path';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_collapsePanel => 'Collapse panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_expandPanel => 'Expand panel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_noLocation => 'No location';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_followPacket => 'Lock view to packet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathMap_unfollowPacket => 'Unlock view from packet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 '../l10n/l10n.dart';
|
||||||
import '../services/app_debug_log_service.dart';
|
import '../services/app_debug_log_service.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
import '../widgets/adaptive_app_bar_title.dart';
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
|
|
||||||
@@ -58,27 +59,57 @@ class AppDebugLogScreen extends StatelessWidget {
|
|||||||
child: hasEntries
|
child: hasEntries
|
||||||
? ListView.separated(
|
? ListView.separated(
|
||||||
itemCount: entries.length,
|
itemCount: entries.length,
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) =>
|
||||||
|
const Divider(height: 1, color: MeshPalette.line),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final entry = entries[index];
|
final entry = entries[index];
|
||||||
return ListTile(
|
return Container(
|
||||||
dense: true,
|
color: MeshPalette.bg,
|
||||||
leading: _buildLevelIcon(context, entry.level),
|
padding: const EdgeInsets.symmetric(
|
||||||
title: Text(
|
horizontal: 16,
|
||||||
'[${entry.tag}] ${entry.message}',
|
vertical: 8,
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
child: Row(
|
||||||
entry.formattedTime,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(
|
children: [
|
||||||
fontSize: 10,
|
_buildLevelIcon(context, entry.level),
|
||||||
color: Theme.of(
|
const SizedBox(width: 10),
|
||||||
context,
|
Expanded(
|
||||||
).colorScheme.onSurfaceVariant,
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '[${entry.tag}] ',
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: _levelColor(entry.level),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: entry.message,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: MeshPalette.ink2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
entry.formattedTime,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 9.5,
|
||||||
|
color: MeshPalette.ink4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -87,29 +118,25 @@ class AppDebugLogScreen extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.bug_report_outlined,
|
Icons.bug_report_outlined,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: MeshPalette.ink3,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.debugLog_noEntries,
|
context.l10n.debugLog_noEntries,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Theme.of(
|
color: MeshPalette.ink3,
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.debugLog_enableInSettings,
|
context.l10n.debugLog_enableInSettings,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Theme.of(
|
color: MeshPalette.ink3,
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -121,19 +148,37 @@ class AppDebugLogScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) {
|
Color _levelColor(AppDebugLogLevel level) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case AppDebugLogLevel.info:
|
case AppDebugLogLevel.info:
|
||||||
return Icon(Icons.info_outline, size: 18, color: colorScheme.primary);
|
return MeshPalette.blue;
|
||||||
case AppDebugLogLevel.warning:
|
case AppDebugLogLevel.warning:
|
||||||
return Icon(
|
return MeshPalette.warn;
|
||||||
|
case AppDebugLogLevel.error:
|
||||||
|
return MeshPalette.alert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLevelIcon(BuildContext context, AppDebugLogLevel level) {
|
||||||
|
switch (level) {
|
||||||
|
case AppDebugLogLevel.info:
|
||||||
|
return const Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 18,
|
||||||
|
color: MeshPalette.blue,
|
||||||
|
);
|
||||||
|
case AppDebugLogLevel.warning:
|
||||||
|
return const Icon(
|
||||||
Icons.warning_amber_outlined,
|
Icons.warning_amber_outlined,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: colorScheme.tertiary,
|
color: MeshPalette.warn,
|
||||||
);
|
);
|
||||||
case AppDebugLogLevel.error:
|
case AppDebugLogLevel.error:
|
||||||
return Icon(Icons.error_outline, size: 18, color: colorScheme.error);
|
return const Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 18,
|
||||||
|
color: MeshPalette.alert,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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 '../l10n/l10n.dart';
|
||||||
import '../services/ble_debug_log_service.dart';
|
import '../services/ble_debug_log_service.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
import '../widgets/adaptive_app_bar_title.dart';
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle),
|
title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle),
|
||||||
|
centerTitle: true,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: context.l10n.debugLog_copyLog,
|
tooltip: context.l10n.debugLog_copyLog,
|
||||||
@@ -101,23 +103,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||||||
itemCount: showingFrames
|
itemCount: showingFrames
|
||||||
? entries.length
|
? entries.length
|
||||||
: rawEntries.length,
|
: rawEntries.length,
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) =>
|
||||||
|
const Divider(height: 1, color: MeshPalette.line),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (showingFrames) {
|
if (showingFrames) {
|
||||||
final entry = entries[index];
|
final entry = entries[index];
|
||||||
final time =
|
final time =
|
||||||
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
|
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
|
||||||
return ListTile(
|
return GestureDetector(
|
||||||
dense: true,
|
|
||||||
title: Text(entry.description),
|
|
||||||
subtitle: Text('${entry.hexPreview}\n$time'),
|
|
||||||
isThreeLine: true,
|
|
||||||
leading: Icon(
|
|
||||||
entry.outgoing
|
|
||||||
? Icons.upload
|
|
||||||
: Icons.download,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
onLongPress: () async {
|
onLongPress: () async {
|
||||||
await Clipboard.setData(
|
await Clipboard.setData(
|
||||||
ClipboardData(
|
ClipboardData(
|
||||||
@@ -131,6 +124,60 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
child: Container(
|
||||||
|
color: MeshPalette.bg,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
entry.outgoing
|
||||||
|
? Icons.upload
|
||||||
|
: Icons.download,
|
||||||
|
size: 18,
|
||||||
|
color: entry.outgoing
|
||||||
|
? MeshPalette.blue
|
||||||
|
: MeshPalette.signal,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
entry.description,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: MeshPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
entry.hexPreview,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 10,
|
||||||
|
color: MeshPalette.ink3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 9.5,
|
||||||
|
color: MeshPalette.ink4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,18 +185,65 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
|||||||
final info = _decodeRawPacket(entry.payload);
|
final info = _decodeRawPacket(entry.payload);
|
||||||
final time =
|
final time =
|
||||||
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
|
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
|
||||||
return ListTile(
|
return GestureDetector(
|
||||||
dense: true,
|
|
||||||
title: Text(info.title),
|
|
||||||
subtitle: Text('${info.summary}\n$time'),
|
|
||||||
isThreeLine: true,
|
|
||||||
leading: const Icon(Icons.download, size: 18),
|
|
||||||
onTap: () => _showRawDialog(context, info),
|
onTap: () => _showRawDialog(context, info),
|
||||||
|
child: Container(
|
||||||
|
color: MeshPalette.bg,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.download,
|
||||||
|
size: 18,
|
||||||
|
color: MeshPalette.signal,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
info.title,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: MeshPalette.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
info.summary,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 10,
|
||||||
|
color: MeshPalette.ink3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 9.5,
|
||||||
|
color: MeshPalette.ink4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: Center(
|
: Center(
|
||||||
child: Text(context.l10n.debugLog_noBleActivity),
|
child: Text(
|
||||||
|
context.l10n.debugLog_noBleActivity,
|
||||||
|
style: const TextStyle(color: MeshPalette.ink3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import '../models/translation_support.dart';
|
|||||||
import '../services/app_settings_service.dart';
|
import '../services/app_settings_service.dart';
|
||||||
import '../services/chat_text_scale_service.dart';
|
import '../services/chat_text_scale_service.dart';
|
||||||
import '../services/translation_service.dart';
|
import '../services/translation_service.dart';
|
||||||
import '../helpers/contact_ui.dart';
|
|
||||||
import '../widgets/byte_count_input.dart';
|
import '../widgets/byte_count_input.dart';
|
||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
import '../widgets/chat_zoom_wrapper.dart';
|
import '../widgets/chat_zoom_wrapper.dart';
|
||||||
@@ -39,6 +38,8 @@ import '../widgets/radio_stats_entry.dart';
|
|||||||
import '../widgets/sync_progress_overlay.dart';
|
import '../widgets/sync_progress_overlay.dart';
|
||||||
import '../widgets/translated_message_content.dart';
|
import '../widgets/translated_message_content.dart';
|
||||||
import '../widgets/unread_divider.dart';
|
import '../widgets/unread_divider.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import 'channel_message_path_screen.dart';
|
import 'channel_message_path_screen.dart';
|
||||||
import 'map_screen.dart';
|
import 'map_screen.dart';
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
totalMessages: messages.length,
|
totalMessages: messages.length,
|
||||||
onJumped: () {
|
onJumped: () {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_scrollToMessage(anchor!.messageId);
|
_scrollToMessage(anchor!.messageId, quiet: true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -193,9 +194,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _scrollToMessage(String messageId) async {
|
Future<void> _scrollToMessage(String messageId, {bool quiet = false}) async {
|
||||||
final key = _messageKeys[messageId];
|
final key = _messageKeys[messageId];
|
||||||
if (key == null) {
|
if (key == null) {
|
||||||
|
// The auto unread-jump can resolve a frame after navigating away;
|
||||||
|
// a deactivated context can't host a snackbar.
|
||||||
|
if (quiet || !mounted || !context.mounted) return;
|
||||||
showDismissibleSnackBar(
|
showDismissibleSnackBar(
|
||||||
context,
|
context,
|
||||||
content: Text(context.l10n.chat_originalMessageNotFound),
|
content: Text(context.l10n.chat_originalMessageNotFound),
|
||||||
@@ -491,6 +495,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
final settingsService = context.watch<AppSettingsService>();
|
final settingsService = context.watch<AppSettingsService>();
|
||||||
final enableTracing = settingsService.settings.enableMessageTracing;
|
final enableTracing = settingsService.settings.enableMessageTracing;
|
||||||
final isOutgoing = message.isOutgoing;
|
final isOutgoing = message.isOutgoing;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final gifId = GifHelper.parseGif(message.text);
|
final gifId = GifHelper.parseGif(message.text);
|
||||||
final poi = parseMarkerText(message.text);
|
final poi = parseMarkerText(message.text);
|
||||||
final translatedDisplayText =
|
final translatedDisplayText =
|
||||||
@@ -507,9 +512,34 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
? message.pathVariants.first
|
? message.pathVariants.first
|
||||||
: Uint8List(0));
|
: Uint8List(0));
|
||||||
|
|
||||||
|
// Bubble colors — outgoing uses MeshPalette.me / meBorder / meInk.
|
||||||
|
final bubbleColor = isOutgoing
|
||||||
|
? MeshPalette.me
|
||||||
|
: scheme.surfaceContainerLow;
|
||||||
|
final bubbleBorder = isOutgoing
|
||||||
|
? MeshPalette.meBorder
|
||||||
|
: scheme.outlineVariant;
|
||||||
|
final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
|
||||||
|
final metaColor = textColor.withValues(alpha: 0.65);
|
||||||
|
const bodyFontSize = 14.0;
|
||||||
|
|
||||||
|
// Asymmetric radius matching chat_screen bubbles.
|
||||||
|
final borderRadius = isOutgoing
|
||||||
|
? const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(MeshRadii.lg),
|
||||||
|
topRight: Radius.circular(MeshRadii.lg),
|
||||||
|
bottomLeft: Radius.circular(MeshRadii.lg),
|
||||||
|
bottomRight: Radius.circular(MeshRadii.xs),
|
||||||
|
)
|
||||||
|
: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(MeshRadii.xs),
|
||||||
|
topRight: Radius.circular(MeshRadii.lg),
|
||||||
|
bottomLeft: Radius.circular(MeshRadii.lg),
|
||||||
|
bottomRight: Radius.circular(MeshRadii.lg),
|
||||||
|
);
|
||||||
|
|
||||||
const maxSwipeOffset = 64.0;
|
const maxSwipeOffset = 64.0;
|
||||||
const replySwipeThreshold = 64.0;
|
const replySwipeThreshold = 64.0;
|
||||||
const bodyFontSize = 14.0;
|
|
||||||
final messageBody = LayoutBuilder(
|
final messageBody = LayoutBuilder(
|
||||||
builder: (context, constraints) => Column(
|
builder: (context, constraints) => Column(
|
||||||
crossAxisAlignment: isOutgoing
|
crossAxisAlignment: isOutgoing
|
||||||
@@ -520,11 +550,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
mainAxisAlignment: isOutgoing
|
mainAxisAlignment: isOutgoing
|
||||||
? MainAxisAlignment.end
|
? MainAxisAlignment.end
|
||||||
: MainAxisAlignment.start,
|
: MainAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
if (!isOutgoing) ...[
|
if (!isOutgoing) ...[
|
||||||
_buildAvatar(message.senderName),
|
_buildAvatar(message.senderName),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 6),
|
||||||
],
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -540,15 +570,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
vertical: 8,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: constraints.maxWidth * 0.65,
|
maxWidth: constraints.maxWidth * 0.72,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isOutgoing
|
color: bubbleColor,
|
||||||
? Theme.of(context).colorScheme.primaryContainer
|
borderRadius: borderRadius,
|
||||||
: Theme.of(
|
border: Border.all(color: bubbleBorder, width: 1),
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -564,14 +591,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
: EdgeInsets.zero,
|
: EdgeInsets.zero,
|
||||||
child: Text(
|
child: Text(
|
||||||
message.senderName,
|
message.senderName,
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w700,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: _colorForName(message.senderName),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (gifId == null) const SizedBox(height: 4),
|
if (gifId == null) const SizedBox(height: 2),
|
||||||
],
|
],
|
||||||
if (message.replyToMessageId != null) ...[
|
if (message.replyToMessageId != null) ...[
|
||||||
_buildReplyPreview(message, textScale),
|
_buildReplyPreview(message, textScale),
|
||||||
@@ -594,13 +621,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
url:
|
url:
|
||||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
fallbackTextColor: isOutgoing
|
fallbackTextColor: textColor.withValues(
|
||||||
? Theme.of(context)
|
alpha: 0.7,
|
||||||
.colorScheme
|
),
|
||||||
.onPrimaryContainer
|
|
||||||
.withValues(alpha: 0.7)
|
|
||||||
: Theme.of(context).colorScheme.onSurface
|
|
||||||
.withValues(alpha: 0.6),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -615,40 +638,48 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
displayText: translatedDisplayText,
|
displayText: translatedDisplayText,
|
||||||
originalText: originalDisplayText,
|
originalText: originalDisplayText,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
fontSize: bodyFontSize * textScale,
|
fontSize: bodyFontSize * textScale,
|
||||||
),
|
),
|
||||||
originalStyle: TextStyle(
|
originalStyle: TextStyle(
|
||||||
fontSize: bodyFontSize * textScale,
|
fontSize: bodyFontSize * textScale,
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
color: Theme.of(context)
|
color: textColor.withValues(alpha: 0.72),
|
||||||
.colorScheme
|
|
||||||
.onSurface
|
|
||||||
.withValues(alpha: 0.72),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (enableTracing && displayPath.isNotEmpty) ...[
|
if (enableTracing && displayPath.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 3),
|
||||||
Padding(
|
Padding(
|
||||||
padding: gifId != null
|
padding: gifId != null
|
||||||
? const EdgeInsets.symmetric(horizontal: 8)
|
? const EdgeInsets.symmetric(horizontal: 8)
|
||||||
: EdgeInsets.zero,
|
: EdgeInsets.zero,
|
||||||
child: Text(
|
child: Row(
|
||||||
context.l10n.channels_via(
|
mainAxisSize: MainAxisSize.min,
|
||||||
_formatPathPrefixes(displayPath),
|
children: [
|
||||||
),
|
RouteChip(
|
||||||
style: TextStyle(
|
isDirect: (message.pathLength ?? -1) >= 0,
|
||||||
fontSize: 11 * textScale,
|
hops: (message.pathLength ?? -1) >= 0
|
||||||
color: Theme.of(
|
? message.pathLength
|
||||||
context,
|
: null,
|
||||||
).colorScheme.onSurfaceVariant,
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
context.l10n.channels_via(
|
||||||
|
_formatPathPrefixes(displayPath),
|
||||||
|
),
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 9.5 * textScale,
|
||||||
|
color: metaColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 3),
|
||||||
Padding(
|
Padding(
|
||||||
padding: gifId != null
|
padding: gifId != null
|
||||||
? const EdgeInsets.only(
|
? const EdgeInsets.only(
|
||||||
@@ -662,30 +693,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_formatTime(context, message.timestamp),
|
_formatTime(context, message.timestamp),
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 11 * textScale,
|
fontSize: 10 * textScale,
|
||||||
color: Theme.of(
|
color: metaColor,
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (enableTracing && message.repeatCount > 0) ...[
|
if (enableTracing && message.repeatCount > 0) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Icon(
|
Icon(
|
||||||
Icons.repeat,
|
Icons.repeat,
|
||||||
size: 12 * textScale,
|
size: 11 * textScale,
|
||||||
color: Theme.of(
|
color: metaColor,
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 2),
|
const SizedBox(width: 2),
|
||||||
Text(
|
Text(
|
||||||
'${message.repeatCount}',
|
'${message.repeatCount}',
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 11 * textScale,
|
fontSize: 10 * textScale,
|
||||||
color: Theme.of(
|
color: metaColor,
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -705,6 +730,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
isFailed:
|
isFailed:
|
||||||
message.status ==
|
message.status ==
|
||||||
ChannelMessageStatus.failed,
|
ChannelMessageStatus.failed,
|
||||||
|
onColor: metaColor,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -720,7 +746,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
if (message.reactions.isNotEmpty) ...[
|
if (message.reactions.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
|
padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
|
||||||
child: _buildReactionsDisplay(message),
|
child: _buildReactionsDisplay(message),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -739,7 +765,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
child: messageBody,
|
child: messageBody,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -836,7 +862,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(MeshRadii.sm),
|
||||||
border: Border(
|
border: Border(
|
||||||
left: BorderSide(color: colorScheme.primary, width: 3),
|
left: BorderSide(color: colorScheme.primary, width: 3),
|
||||||
),
|
),
|
||||||
@@ -849,9 +875,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11 * textScale,
|
fontSize: 11 * textScale,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: isOwnNode
|
color: isOwnNode ? colorScheme.primary : colorScheme.onSurface,
|
||||||
? Theme.of(context).colorScheme.primary
|
|
||||||
: Theme.of(context).colorScheme.onSurface,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -863,6 +887,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildReactionsDisplay(ChannelMessage message) {
|
Widget _buildReactionsDisplay(ChannelMessage message) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
runSpacing: 6,
|
runSpacing: 6,
|
||||||
@@ -873,27 +898,29 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
color: scheme.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||||
border: Border.all(
|
border: Border.all(color: scheme.outlineVariant, width: 1),
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.outline.withValues(alpha: 0.3),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
Text(
|
||||||
|
emoji,
|
||||||
|
style: MeshTheme.emoji(fontSize: 16),
|
||||||
|
textHeightBehavior: const TextHeightBehavior(
|
||||||
|
applyHeightToFirstAscent: false,
|
||||||
|
applyHeightToLastDescent: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (count > 1) ...[
|
if (count > 1) ...[
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'$count',
|
'$count',
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w700,
|
||||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
color: scheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -912,20 +939,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
String senderName, {
|
String senderName, {
|
||||||
Widget? trailing,
|
Widget? trailing,
|
||||||
}) {
|
}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final textColor = isOutgoing
|
final textColor = isOutgoing ? MeshPalette.meInk : scheme.onSurface;
|
||||||
? colorScheme.onPrimaryContainer
|
|
||||||
: colorScheme.onSurface;
|
|
||||||
final metaColor = textColor.withValues(alpha: 0.7);
|
final metaColor = textColor.withValues(alpha: 0.7);
|
||||||
final channelColor = widget.channel.isPublicChannel
|
|
||||||
? Colors.orange
|
|
||||||
: Colors.blue;
|
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.location_on_outlined, color: channelColor),
|
icon: Icon(Icons.location_on_outlined, color: scheme.primary),
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -991,41 +1013,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAvatar(String senderName) {
|
Widget _buildAvatar(String senderName) {
|
||||||
final initial = firstCharacterOrEmoji(senderName);
|
return AvatarCircle(name: senderName, size: 32);
|
||||||
final color = colorForName(senderName);
|
|
||||||
|
|
||||||
return CircleAvatar(
|
|
||||||
radius: 18,
|
|
||||||
backgroundColor: color.withValues(alpha: 0.2),
|
|
||||||
child: Text(
|
|
||||||
initial,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildReplyBanner(double textScale) {
|
Widget _buildReplyBanner(double textScale) {
|
||||||
final message = _replyingToMessage!;
|
final message = _replyingToMessage!;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
color: scheme.surfaceContainerHigh,
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
|
bottom: BorderSide(color: scheme.outlineVariant, width: 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.reply, size: 18, color: scheme.primary),
|
||||||
Icons.reply,
|
|
||||||
size: 18,
|
|
||||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -1033,10 +1038,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
context.l10n.chat_replyingTo(message.senderName),
|
context.l10n.chat_replyingTo(message.senderName),
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 12 * textScale,
|
fontSize: 11 * textScale,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w700,
|
||||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
color: scheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
@@ -1045,9 +1050,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11 * textScale,
|
fontSize: 11 * textScale,
|
||||||
color: Theme.of(
|
color: scheme.onSurfaceVariant,
|
||||||
context,
|
|
||||||
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1056,7 +1059,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close, size: 18),
|
icon: const Icon(Icons.close, size: 18),
|
||||||
onPressed: _cancelReply,
|
onPressed: _cancelReply,
|
||||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
color: scheme.onSurfaceVariant,
|
||||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1068,6 +1071,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
final maxBytes = maxChannelMessageBytes(connector.selfName);
|
final maxBytes = maxChannelMessageBytes(connector.selfName);
|
||||||
final settings = context.watch<AppSettingsService>().settings;
|
final settings = context.watch<AppSettingsService>().settings;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -1081,123 +1085,166 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: scheme.surface,
|
||||||
boxShadow: [
|
border: Border(
|
||||||
BoxShadow(
|
top: BorderSide(color: scheme.outlineVariant, width: 1),
|
||||||
color: Colors.black.withValues(alpha: 0.1),
|
),
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, -2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: SafeArea(
|
||||||
children: [
|
child: Padding(
|
||||||
IconButton(
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
icon: const Icon(Icons.gif_box),
|
child: Row(
|
||||||
onPressed: () => _showGifPicker(context),
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
tooltip: context.l10n.chat_sendGif,
|
children: [
|
||||||
),
|
IconButton(
|
||||||
if (settings.translationEnabled)
|
icon: const Icon(Icons.gif_box),
|
||||||
MessageTranslationButton(
|
onPressed: () => _showGifPicker(context),
|
||||||
enabled: settings.composerTranslationEnabled,
|
tooltip: context.l10n.chat_sendGif,
|
||||||
languageCode: settings.translationTargetLanguageCode,
|
),
|
||||||
onPressed: _showTranslationOptions,
|
if (settings.translationEnabled)
|
||||||
),
|
MessageTranslationButton(
|
||||||
Expanded(
|
enabled: settings.composerTranslationEnabled,
|
||||||
child: ValueListenableBuilder<TextEditingValue>(
|
languageCode: settings.translationTargetLanguageCode,
|
||||||
valueListenable: _textController,
|
onPressed: _showTranslationOptions,
|
||||||
builder: (context, value, child) {
|
),
|
||||||
final gifId = GifHelper.parseGif(value.text);
|
Expanded(
|
||||||
if (gifId != null) {
|
child: ValueListenableBuilder<TextEditingValue>(
|
||||||
return Focus(
|
valueListenable: _textController,
|
||||||
autofocus: true,
|
builder: (context, value, child) {
|
||||||
onKeyEvent: (node, event) {
|
final gifId = GifHelper.parseGif(value.text);
|
||||||
if (event is KeyDownEvent &&
|
if (gifId != null) {
|
||||||
(event.logicalKey == LogicalKeyboardKey.enter ||
|
return Focus(
|
||||||
event.logicalKey ==
|
autofocus: true,
|
||||||
LogicalKeyboardKey.numpadEnter)) {
|
onKeyEvent: (node, event) {
|
||||||
_sendMessage();
|
if (event is KeyDownEvent &&
|
||||||
return KeyEventResult.handled;
|
(event.logicalKey ==
|
||||||
}
|
LogicalKeyboardKey.enter ||
|
||||||
return KeyEventResult.ignored;
|
event.logicalKey ==
|
||||||
},
|
LogicalKeyboardKey.numpadEnter)) {
|
||||||
child: Row(
|
_sendMessage();
|
||||||
children: [
|
return KeyEventResult.handled;
|
||||||
Expanded(
|
}
|
||||||
child: ClipRRect(
|
return KeyEventResult.ignored;
|
||||||
borderRadius: BorderRadius.circular(12),
|
},
|
||||||
child: GifMessage(
|
child: Row(
|
||||||
url:
|
children: [
|
||||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
Expanded(
|
||||||
backgroundColor: Theme.of(
|
child: ClipRRect(
|
||||||
context,
|
borderRadius: BorderRadius.circular(12),
|
||||||
).colorScheme.surfaceContainerHighest,
|
child: GifMessage(
|
||||||
fallbackTextColor: Theme.of(context)
|
url:
|
||||||
.colorScheme
|
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||||
.onSurface
|
backgroundColor:
|
||||||
.withValues(alpha: 0.6),
|
scheme.surfaceContainerHighest,
|
||||||
maxSize: 160,
|
fallbackTextColor: scheme.onSurface
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
|
maxSize: 160,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () {
|
||||||
|
_textController.clear();
|
||||||
|
_textFieldFocusNode.requestFocus();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ByteCountedTextField(
|
||||||
|
maxBytes: maxBytes,
|
||||||
|
controller: _textController,
|
||||||
|
focusNode: _textFieldFocusNode,
|
||||||
|
hintText: context.l10n.chat_typeMessage,
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
encoder:
|
||||||
|
(connector.isChannelSmazEnabled(
|
||||||
|
widget.channel.index,
|
||||||
|
) ||
|
||||||
|
connector.isChannelCyr2LatEnabled(
|
||||||
|
widget.channel.index,
|
||||||
|
))
|
||||||
|
? (text) => connector.prepareChannelOutboundText(
|
||||||
|
widget.channel.index,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: context.l10n.chat_typeMessage,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
MeshRadii.pill,
|
||||||
|
),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: scheme.outlineVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
enabledBorder: OutlineInputBorder(
|
||||||
IconButton(
|
borderRadius: BorderRadius.circular(
|
||||||
icon: const Icon(Icons.close),
|
MeshRadii.pill,
|
||||||
onPressed: () {
|
),
|
||||||
_textController.clear();
|
borderSide: BorderSide(
|
||||||
_textFieldFocusNode.requestFocus();
|
color: scheme.outlineVariant,
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
],
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
MeshRadii.pill,
|
||||||
|
),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: scheme.primary,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: scheme.surfaceContainerLow,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 18,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
ValueListenableBuilder<TextEditingValue>(
|
||||||
|
valueListenable: _textController,
|
||||||
|
builder: (context, value, _) {
|
||||||
|
final hasText = value.text.trim().isNotEmpty;
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
child: IconButton.filled(
|
||||||
|
icon: const Icon(Icons.send, size: 20),
|
||||||
|
tooltip: context.l10n.chat_sendMessage,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: hasText
|
||||||
|
? scheme.primary
|
||||||
|
: scheme.surfaceContainerHighest,
|
||||||
|
foregroundColor: hasText
|
||||||
|
? scheme.onPrimary
|
||||||
|
: scheme.onSurfaceVariant,
|
||||||
|
minimumSize: const Size(40, 40),
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
),
|
||||||
|
onPressed: hasText
|
||||||
|
? () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
_sendMessage();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return ByteCountedTextField(
|
),
|
||||||
maxBytes: maxBytes,
|
],
|
||||||
controller: _textController,
|
|
||||||
focusNode: _textFieldFocusNode,
|
|
||||||
hintText: context.l10n.chat_typeMessage,
|
|
||||||
onSubmitted: (_) => _sendMessage(),
|
|
||||||
encoder:
|
|
||||||
(connector.isChannelSmazEnabled(
|
|
||||||
widget.channel.index,
|
|
||||||
) ||
|
|
||||||
connector.isChannelCyr2LatEnabled(
|
|
||||||
widget.channel.index,
|
|
||||||
))
|
|
||||||
? (text) => connector.prepareChannelOutboundText(
|
|
||||||
widget.channel.index,
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: context.l10n.chat_typeMessage,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
filled: true,
|
|
||||||
fillColor: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainerLow,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20,
|
|
||||||
vertical: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.send),
|
|
||||||
tooltip: context.l10n.chat_sendMessage,
|
|
||||||
onPressed: _sendMessage,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1338,12 +1385,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
) &&
|
) &&
|
||||||
(message.translatedText?.trim().isEmpty ?? true);
|
(message.translatedText?.trim().isEmpty ?? true);
|
||||||
|
|
||||||
showModalBottomSheet(
|
showMeshSheet(
|
||||||
context: context,
|
context,
|
||||||
builder: (sheetContext) => SafeArea(
|
builder: (sheetContext) => SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
BottomSheetHeader(
|
||||||
|
title: message.text.length > 40
|
||||||
|
? '${message.text.substring(0, 40)}…'
|
||||||
|
: message.text,
|
||||||
|
subtitle: message.senderName.isNotEmpty
|
||||||
|
? message.senderName
|
||||||
|
: null,
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.reply),
|
leading: const Icon(Icons.reply),
|
||||||
title: Text(context.l10n.chat_reply),
|
title: Text(context.l10n.chat_reply),
|
||||||
@@ -1402,7 +1457,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
_markAsUnread(message);
|
_markAsUnread(message);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.delete_outline,
|
Icons.delete_outline,
|
||||||
@@ -1417,6 +1472,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
await _deleteMessage(message);
|
await _deleteMessage(message);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1499,6 +1555,23 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||||
.join(',');
|
.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deterministic name-to-hue mapping consistent with [AvatarCircle].
|
||||||
|
Color _colorForName(String name) {
|
||||||
|
const hues = [
|
||||||
|
MeshPalette.blue,
|
||||||
|
MeshPalette.magenta,
|
||||||
|
MeshPalette.signal,
|
||||||
|
MeshPalette.warn,
|
||||||
|
Color(0xFF8FA8F0),
|
||||||
|
Color(0xFF6FD9CE),
|
||||||
|
];
|
||||||
|
var h = 0;
|
||||||
|
for (final c in name.codeUnits) {
|
||||||
|
h = (h * 31 + c) & 0x7fffffff;
|
||||||
|
}
|
||||||
|
return hues[h % hues.length];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SwipeReplyBubble extends StatefulWidget {
|
class _SwipeReplyBubble extends StatefulWidget {
|
||||||
@@ -1640,7 +1713,7 @@ class _SwipeReplyBubbleState extends State<_SwipeReplyBubble> {
|
|||||||
onPointerUp: (event) => _handleSwipePointerUp(event.position),
|
onPointerUp: (event) => _handleSwipePointerUp(event.position),
|
||||||
onPointerCancel: (_) => _resetSwipe(),
|
onPointerCancel: (_) => _resetSwipe(),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 8),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+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 '../widgets/byte_count_input.dart';
|
||||||
import 'channel_message_path_screen.dart';
|
import 'channel_message_path_screen.dart';
|
||||||
import 'map_screen.dart';
|
import 'map_screen.dart';
|
||||||
import '../helpers/contact_ui.dart';
|
|
||||||
import '../widgets/emoji_picker.dart';
|
import '../widgets/emoji_picker.dart';
|
||||||
import '../widgets/gif_message.dart';
|
import '../widgets/gif_message.dart';
|
||||||
import '../widgets/jump_to_bottom_button.dart';
|
import '../widgets/jump_to_bottom_button.dart';
|
||||||
@@ -43,6 +42,8 @@ import '../widgets/translated_message_content.dart';
|
|||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
import '../widgets/unread_divider.dart';
|
import '../widgets/unread_divider.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import 'telemetry_screen.dart';
|
import 'telemetry_screen.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
@@ -448,119 +449,157 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
Widget _buildInputBar(MeshCoreConnector connector) {
|
Widget _buildInputBar(MeshCoreConnector connector) {
|
||||||
final maxBytes = maxContactMessageBytes();
|
final maxBytes = maxContactMessageBytes();
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final settings = context.watch<AppSettingsService>().settings;
|
final settings = context.watch<AppSettingsService>().settings;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surface,
|
color: scheme.surface,
|
||||||
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
|
border: Border(top: BorderSide(color: scheme.outlineVariant, width: 1)),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Row(
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
IconButton(
|
child: Row(
|
||||||
icon: const Icon(Icons.gif_box),
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
onPressed: () => _showGifPicker(context),
|
children: [
|
||||||
tooltip: context.l10n.chat_sendGif,
|
IconButton(
|
||||||
),
|
icon: const Icon(Icons.gif_box),
|
||||||
if (settings.translationEnabled)
|
onPressed: () => _showGifPicker(context),
|
||||||
MessageTranslationButton(
|
tooltip: context.l10n.chat_sendGif,
|
||||||
enabled: settings.composerTranslationEnabled,
|
|
||||||
languageCode: settings.translationTargetLanguageCode,
|
|
||||||
onPressed: _showTranslationOptions,
|
|
||||||
),
|
),
|
||||||
Expanded(
|
if (settings.translationEnabled)
|
||||||
child: ValueListenableBuilder<TextEditingValue>(
|
MessageTranslationButton(
|
||||||
valueListenable: _textController,
|
enabled: settings.composerTranslationEnabled,
|
||||||
builder: (context, value, child) {
|
languageCode: settings.translationTargetLanguageCode,
|
||||||
final gifId = GifHelper.parseGif(value.text);
|
onPressed: _showTranslationOptions,
|
||||||
if (gifId != null) {
|
),
|
||||||
return Focus(
|
Expanded(
|
||||||
autofocus: true,
|
child: ValueListenableBuilder<TextEditingValue>(
|
||||||
onKeyEvent: (node, event) {
|
valueListenable: _textController,
|
||||||
if (event is KeyDownEvent &&
|
builder: (context, value, child) {
|
||||||
(event.logicalKey == LogicalKeyboardKey.enter ||
|
final gifId = GifHelper.parseGif(value.text);
|
||||||
event.logicalKey ==
|
if (gifId != null) {
|
||||||
LogicalKeyboardKey.numpadEnter)) {
|
return Focus(
|
||||||
_sendMessage(connector);
|
autofocus: true,
|
||||||
return KeyEventResult.handled;
|
onKeyEvent: (node, event) {
|
||||||
}
|
if (event is KeyDownEvent &&
|
||||||
return KeyEventResult.ignored;
|
(event.logicalKey == LogicalKeyboardKey.enter ||
|
||||||
},
|
event.logicalKey ==
|
||||||
child: Row(
|
LogicalKeyboardKey.numpadEnter)) {
|
||||||
children: [
|
_sendMessage(connector);
|
||||||
Expanded(
|
return KeyEventResult.handled;
|
||||||
child: ClipRRect(
|
}
|
||||||
borderRadius: BorderRadius.circular(12),
|
return KeyEventResult.ignored;
|
||||||
child: GifMessage(
|
},
|
||||||
url:
|
child: Row(
|
||||||
'https://media.giphy.com/media/$gifId/giphy.gif',
|
children: [
|
||||||
backgroundColor:
|
Expanded(
|
||||||
colorScheme.surfaceContainerHighest,
|
child: ClipRRect(
|
||||||
fallbackTextColor: colorScheme.onSurface
|
borderRadius: BorderRadius.circular(12),
|
||||||
.withValues(alpha: 0.6),
|
child: GifMessage(
|
||||||
maxSize: 160,
|
url:
|
||||||
|
'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||||
|
backgroundColor:
|
||||||
|
scheme.surfaceContainerHighest,
|
||||||
|
fallbackTextColor: scheme.onSurface
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
|
maxSize: 160,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () {
|
||||||
|
_textController.clear();
|
||||||
|
_textFieldFocusNode.requestFocus();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ByteCountedTextField(
|
||||||
|
maxBytes: maxBytes,
|
||||||
|
controller: _textController,
|
||||||
|
focusNode: _textFieldFocusNode,
|
||||||
|
hintText: context.l10n.chat_typeMessage,
|
||||||
|
onSubmitted: (_) => _sendMessage(connector),
|
||||||
|
encoder:
|
||||||
|
(connector.isContactSmazEnabled(
|
||||||
|
widget.contact.publicKeyHex,
|
||||||
|
) ||
|
||||||
|
connector.isContactCyr2LatEnabled(
|
||||||
|
widget.contact.publicKeyHex,
|
||||||
|
))
|
||||||
|
? (text) => connector.prepareContactOutboundText(
|
||||||
|
widget.contact,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: context.l10n.chat_typeMessage,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||||
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||||
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: scheme.primary,
|
||||||
|
width: 1.5,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
IconButton(
|
filled: true,
|
||||||
icon: const Icon(Icons.close),
|
fillColor: scheme.surfaceContainerLow,
|
||||||
onPressed: () {
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
_textController.clear();
|
horizontal: 18,
|
||||||
_textFieldFocusNode.requestFocus();
|
vertical: 12,
|
||||||
},
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return ByteCountedTextField(
|
),
|
||||||
maxBytes: maxBytes,
|
),
|
||||||
controller: _textController,
|
const SizedBox(width: 6),
|
||||||
focusNode: _textFieldFocusNode,
|
ValueListenableBuilder<TextEditingValue>(
|
||||||
hintText: context.l10n.chat_typeMessage,
|
valueListenable: _textController,
|
||||||
onSubmitted: (_) => _sendMessage(connector),
|
builder: (context, value, _) {
|
||||||
encoder:
|
final hasText = value.text.trim().isNotEmpty;
|
||||||
(connector.isContactSmazEnabled(
|
return AnimatedContainer(
|
||||||
widget.contact.publicKeyHex,
|
duration: const Duration(milliseconds: 180),
|
||||||
) ||
|
curve: Curves.easeInOut,
|
||||||
connector.isContactCyr2LatEnabled(
|
child: IconButton.filled(
|
||||||
widget.contact.publicKeyHex,
|
icon: const Icon(Icons.send, size: 20),
|
||||||
))
|
tooltip: context.l10n.chat_sendMessageTo(
|
||||||
? (text) => connector.prepareContactOutboundText(
|
_resolveContact(connector).name,
|
||||||
widget.contact,
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: context.l10n.chat_typeMessage,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
),
|
||||||
filled: true,
|
style: IconButton.styleFrom(
|
||||||
fillColor: Theme.of(
|
backgroundColor: hasText
|
||||||
context,
|
? scheme.primary
|
||||||
).colorScheme.surfaceContainerLow,
|
: scheme.surfaceContainerHighest,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
foregroundColor: hasText
|
||||||
horizontal: 20,
|
? scheme.onPrimary
|
||||||
vertical: 14,
|
: scheme.onSurfaceVariant,
|
||||||
|
minimumSize: const Size(40, 40),
|
||||||
|
shape: const CircleBorder(),
|
||||||
),
|
),
|
||||||
|
onPressed: hasText
|
||||||
|
? () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
_sendMessage(connector);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
IconButton.filled(
|
|
||||||
icon: const Icon(Icons.send),
|
|
||||||
tooltip: context.l10n.chat_sendMessageTo(
|
|
||||||
_resolveContact(connector).name,
|
|
||||||
),
|
|
||||||
onPressed: () => _sendMessage(connector),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1043,12 +1082,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
) &&
|
) &&
|
||||||
(message.translatedText?.trim().isEmpty ?? true);
|
(message.translatedText?.trim().isEmpty ?? true);
|
||||||
|
|
||||||
showModalBottomSheet(
|
showMeshSheet(
|
||||||
context: context,
|
context,
|
||||||
builder: (sheetContext) => SafeArea(
|
builder: (sheetContext) => SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
BottomSheetHeader(
|
||||||
|
title: message.text.length > 40
|
||||||
|
? '${message.text.substring(0, 40)}…'
|
||||||
|
: message.text,
|
||||||
|
),
|
||||||
// Can't react to your own messages
|
// Can't react to your own messages
|
||||||
if (!message.isOutgoing)
|
if (!message.isOutgoing)
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -1118,7 +1162,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_openChat(context, contact);
|
_openChat(context, contact);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.delete_outline,
|
Icons.delete_outline,
|
||||||
@@ -1133,6 +1177,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
await _deleteMessage(message);
|
await _deleteMessage(message);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1221,20 +1266,45 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
final settingsService = context.watch<AppSettingsService>();
|
final settingsService = context.watch<AppSettingsService>();
|
||||||
final enableTracing = settingsService.settings.enableMessageTracing;
|
final enableTracing = settingsService.settings.enableMessageTracing;
|
||||||
final isOutgoing = message.isOutgoing;
|
final isOutgoing = message.isOutgoing;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final gifId = GifHelper.parseGif(message.text);
|
final gifId = GifHelper.parseGif(message.text);
|
||||||
final poi = parseMarkerText(message.text);
|
final poi = parseMarkerText(message.text);
|
||||||
final isFailed = message.status == MessageStatus.failed;
|
final isFailed = message.status == MessageStatus.failed;
|
||||||
|
|
||||||
|
// Bubble colors — outgoing uses MeshPalette.me / meBorder / meInk.
|
||||||
final bubbleColor = isFailed
|
final bubbleColor = isFailed
|
||||||
? colorScheme.errorContainer
|
? scheme.errorContainer
|
||||||
: (isOutgoing
|
: isOutgoing
|
||||||
? colorScheme.primary
|
? MeshPalette.me
|
||||||
: colorScheme.surfaceContainerHighest);
|
: scheme.surfaceContainerLow;
|
||||||
|
final bubbleBorder = isFailed
|
||||||
|
? scheme.error
|
||||||
|
: isOutgoing
|
||||||
|
? MeshPalette.meBorder
|
||||||
|
: scheme.outlineVariant;
|
||||||
final textColor = isFailed
|
final textColor = isFailed
|
||||||
? colorScheme.onErrorContainer
|
? scheme.onErrorContainer
|
||||||
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
|
: isOutgoing
|
||||||
final metaColor = textColor.withValues(alpha: 0.7);
|
? MeshPalette.meInk
|
||||||
|
: scheme.onSurface;
|
||||||
|
final metaColor = textColor.withValues(alpha: 0.65);
|
||||||
const bodyFontSize = 14.0;
|
const bodyFontSize = 14.0;
|
||||||
|
|
||||||
|
// Asymmetric radius: outgoing — top-left large, others also large; outgoing bottom-right tight.
|
||||||
|
final borderRadius = isOutgoing
|
||||||
|
? const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(MeshRadii.lg),
|
||||||
|
topRight: Radius.circular(MeshRadii.lg),
|
||||||
|
bottomLeft: Radius.circular(MeshRadii.lg),
|
||||||
|
bottomRight: Radius.circular(MeshRadii.xs),
|
||||||
|
)
|
||||||
|
: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(MeshRadii.xs),
|
||||||
|
topRight: Radius.circular(MeshRadii.lg),
|
||||||
|
bottomLeft: Radius.circular(MeshRadii.lg),
|
||||||
|
bottomRight: Radius.circular(MeshRadii.lg),
|
||||||
|
);
|
||||||
|
|
||||||
// Do not strip room-server author bytes here: the parser stores them in
|
// Do not strip room-server author bytes here: the parser stores them in
|
||||||
// fourByteRoomContactKey, so message.text is safe to render as-is.
|
// fourByteRoomContactKey, so message.text is safe to render as-is.
|
||||||
final messageText = message.text;
|
final messageText = message.text;
|
||||||
@@ -1246,8 +1316,9 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
final originalDisplayText = isOutgoing
|
final originalDisplayText = isOutgoing
|
||||||
? message.originalText
|
? message.originalText
|
||||||
: (translatedDisplayText != messageText ? messageText : null);
|
: (translatedDisplayText != messageText ? messageText : null);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: isOutgoing
|
crossAxisAlignment: isOutgoing
|
||||||
? CrossAxisAlignment.end
|
? CrossAxisAlignment.end
|
||||||
@@ -1263,11 +1334,11 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
mainAxisAlignment: isOutgoing
|
mainAxisAlignment: isOutgoing
|
||||||
? MainAxisAlignment.end
|
? MainAxisAlignment.end
|
||||||
: MainAxisAlignment.start,
|
: MainAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
if (!isOutgoing) ...[
|
if (!isOutgoing) ...[
|
||||||
_buildAvatar(senderName, colorScheme),
|
_buildAvatar(senderName),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 6),
|
||||||
],
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -1278,14 +1349,12 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
vertical: 8,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: constraints.maxWidth * 0.65,
|
maxWidth: constraints.maxWidth * 0.72,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bubbleColor,
|
color: bubbleColor,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: borderRadius,
|
||||||
border: isFailed
|
border: Border.all(color: bubbleBorder, width: 1),
|
||||||
? Border.all(color: colorScheme.error)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1301,14 +1370,14 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
: EdgeInsets.zero,
|
: EdgeInsets.zero,
|
||||||
child: Text(
|
child: Text(
|
||||||
senderName,
|
senderName,
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w700,
|
||||||
color: colorScheme.primary,
|
color: _colorForName(senderName),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (gifId == null) const SizedBox(height: 4),
|
if (gifId == null) const SizedBox(height: 2),
|
||||||
],
|
],
|
||||||
if (poi != null)
|
if (poi != null)
|
||||||
_buildPoiMessage(
|
_buildPoiMessage(
|
||||||
@@ -1349,7 +1418,7 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
fontSize: bodyFontSize * textScale,
|
fontSize: bodyFontSize * textScale,
|
||||||
),
|
),
|
||||||
originalStyle: TextStyle(
|
originalStyle: TextStyle(
|
||||||
color: textColor.withValues(alpha: 0.78),
|
color: textColor.withValues(alpha: 0.72),
|
||||||
fontSize: bodyFontSize * textScale,
|
fontSize: bodyFontSize * textScale,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1359,7 +1428,7 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
if (enableTracing &&
|
if (enableTracing &&
|
||||||
isOutgoing &&
|
isOutgoing &&
|
||||||
message.retryCount > 0) ...[
|
message.retryCount > 0) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 3),
|
||||||
Padding(
|
Padding(
|
||||||
padding: gifId != null
|
padding: gifId != null
|
||||||
? const EdgeInsets.symmetric(horizontal: 8)
|
? const EdgeInsets.symmetric(horizontal: 8)
|
||||||
@@ -1372,15 +1441,15 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
.settings
|
.settings
|
||||||
.maxMessageRetries,
|
.maxMessageRetries,
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 10 * textScale,
|
fontSize: 9.5 * textScale,
|
||||||
color: metaColor,
|
color: metaColor,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 3),
|
||||||
|
// Meta row: timestamp + status icon + optional tracing
|
||||||
Padding(
|
Padding(
|
||||||
padding: gifId != null
|
padding: gifId != null
|
||||||
? const EdgeInsets.only(
|
? const EdgeInsets.only(
|
||||||
@@ -1395,13 +1464,13 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_formatTime(message.timestamp),
|
_formatTime(message.timestamp),
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 10 * textScale,
|
fontSize: 10 * textScale,
|
||||||
color: metaColor,
|
color: metaColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isOutgoing) ...[
|
if (isOutgoing) ...[
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 2),
|
||||||
MessageStatusIcon(
|
MessageStatusIcon(
|
||||||
size: 12 * textScale,
|
size: 12 * textScale,
|
||||||
onColor: metaColor,
|
onColor: metaColor,
|
||||||
@@ -1418,25 +1487,21 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
message.tripTimeMs != null &&
|
message.tripTimeMs != null &&
|
||||||
message.status ==
|
message.status ==
|
||||||
MessageStatus.delivered) ...[
|
MessageStatus.delivered) ...[
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 2),
|
||||||
Icon(
|
Icon(
|
||||||
Icons.speed,
|
Icons.speed,
|
||||||
size: 10 * textScale,
|
size: 10 * textScale,
|
||||||
color: isOutgoing
|
color: isOutgoing
|
||||||
? metaColor
|
? metaColor
|
||||||
: Theme.of(
|
: scheme.tertiary,
|
||||||
context,
|
|
||||||
).colorScheme.tertiary,
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
|
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 9 * textScale,
|
fontSize: 9 * textScale,
|
||||||
color: isOutgoing
|
color: isOutgoing
|
||||||
? metaColor
|
? metaColor
|
||||||
: Theme.of(
|
: scheme.tertiary,
|
||||||
context,
|
|
||||||
).colorScheme.tertiary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1454,8 +1519,8 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
if (message.reactions.isNotEmpty) ...[
|
if (message.reactions.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
|
padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
|
||||||
child: _buildReactionsDisplay(context, message, colorScheme),
|
child: _buildReactionsDisplay(context, message, scheme),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -1532,7 +1597,7 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
Widget _buildReactionsDisplay(
|
Widget _buildReactionsDisplay(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Message message,
|
Message message,
|
||||||
ColorScheme colorScheme,
|
ColorScheme scheme,
|
||||||
) {
|
) {
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
@@ -1555,28 +1620,33 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isFailed
|
color: isFailed
|
||||||
? colorScheme.errorContainer
|
? scheme.errorContainer
|
||||||
: colorScheme.secondaryContainer,
|
: scheme.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isFailed
|
color: isFailed ? scheme.error : scheme.outlineVariant,
|
||||||
? colorScheme.error
|
|
||||||
: colorScheme.outline.withValues(alpha: 0.3),
|
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(emoji, style: const TextStyle(fontSize: 16)),
|
Text(
|
||||||
|
emoji,
|
||||||
|
style: MeshTheme.emoji(fontSize: 16),
|
||||||
|
textHeightBehavior: const TextHeightBehavior(
|
||||||
|
applyHeightToFirstAscent: false,
|
||||||
|
applyHeightToLastDescent: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (count > 1) ...[
|
if (count > 1) ...[
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'$count',
|
'$count',
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w700,
|
||||||
color: colorScheme.onSecondaryContainer,
|
color: scheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1587,13 +1657,13 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
height: 8,
|
height: 8,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 1.5,
|
strokeWidth: 1.5,
|
||||||
color: colorScheme.onSecondaryContainer,
|
color: scheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (isFailed) ...[
|
if (isFailed) ...[
|
||||||
const SizedBox(width: 2),
|
const SizedBox(width: 2),
|
||||||
Icon(Icons.replay, size: 10, color: colorScheme.error),
|
Icon(Icons.replay, size: 10, color: scheme.error),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1604,22 +1674,8 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAvatar(String senderName, ColorScheme colorScheme) {
|
Widget _buildAvatar(String senderName) {
|
||||||
final initial = firstCharacterOrEmoji(senderName);
|
return AvatarCircle(name: senderName, size: 32);
|
||||||
final color = colorForName(senderName);
|
|
||||||
|
|
||||||
return CircleAvatar(
|
|
||||||
radius: 18,
|
|
||||||
backgroundColor: color.withValues(alpha: 0.2),
|
|
||||||
child: Text(
|
|
||||||
initial,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatTime(DateTime time) {
|
String _formatTime(DateTime time) {
|
||||||
@@ -1628,3 +1684,20 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
return '$hour:$minute';
|
return '$hour:$minute';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deterministic name-to-hue mapping consistent with [AvatarCircle].
|
||||||
|
Color _colorForName(String name) {
|
||||||
|
const hues = [
|
||||||
|
MeshPalette.blue,
|
||||||
|
MeshPalette.magenta,
|
||||||
|
MeshPalette.signal,
|
||||||
|
MeshPalette.warn,
|
||||||
|
Color(0xFF8FA8F0),
|
||||||
|
Color(0xFF6FD9CE),
|
||||||
|
];
|
||||||
|
var h = 0;
|
||||||
|
for (final c in name.codeUnits) {
|
||||||
|
h = (h * 31 + c) & 0x7fffffff;
|
||||||
|
}
|
||||||
|
return hues[h % hues.length];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
|
|
||||||
class ChromeRequiredScreen extends StatelessWidget {
|
class ChromeRequiredScreen extends StatelessWidget {
|
||||||
const ChromeRequiredScreen({super.key});
|
const ChromeRequiredScreen({super.key});
|
||||||
@@ -7,78 +9,95 @@ class ChromeRequiredScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final theme = Theme.of(context);
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Container(
|
body: SafeArea(
|
||||||
width: double.infinity,
|
child: Center(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
child: SingleChildScrollView(
|
||||||
color: colorScheme.surface,
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 40),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// Icon in tinted circle
|
||||||
padding: const EdgeInsets.all(24),
|
Container(
|
||||||
decoration: BoxDecoration(
|
width: 88,
|
||||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.4),
|
height: 88,
|
||||||
shape: BoxShape.circle,
|
decoration: BoxDecoration(
|
||||||
),
|
shape: BoxShape.circle,
|
||||||
child: Icon(
|
color: scheme.tertiary.withValues(alpha: 0.10),
|
||||||
Icons.browser_not_supported_rounded,
|
border: Border.all(
|
||||||
size: 80,
|
color: scheme.tertiary.withValues(alpha: 0.25),
|
||||||
color: colorScheme.tertiary,
|
width: 1.5,
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
Text(
|
|
||||||
l10n.scanner_chromeRequired,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.headlineMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
l10n.scanner_chromeRequiredMessage,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 48),
|
|
||||||
// We can't really "fix" it for them other than telling them to use Chrome
|
|
||||||
// but we can provide a nice visual.
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.secondaryContainer.withValues(alpha: 0.4),
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
border: Border.all(
|
|
||||||
color: colorScheme.outline.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.info_outline,
|
|
||||||
size: 20,
|
|
||||||
color: colorScheme.secondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
l10n.chrome_bluetoothRequiresChromium,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSecondaryContainer,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
child: Icon(
|
||||||
),
|
Icons.browser_not_supported_rounded,
|
||||||
|
size: 42,
|
||||||
|
color: scheme.tertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
l10n.scanner_chromeRequired,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: scheme.onSurface,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Body text
|
||||||
|
Text(
|
||||||
|
l10n.scanner_chromeRequiredMessage,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
height: 1.55,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Info chip
|
||||||
|
MeshCard(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
color: scheme.secondaryContainer.withValues(alpha: 0.35),
|
||||||
|
borderColor: scheme.outline.withValues(alpha: 0.3),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 18,
|
||||||
|
color: scheme.secondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
l10n.chrome_bluetoothRequiresChromium,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 12,
|
||||||
|
color: scheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../helpers/snack_bar_builder.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/community.dart';
|
import '../models/community.dart';
|
||||||
import '../storage/community_store.dart';
|
import '../storage/community_store.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
import '../widgets/adaptive_app_bar_title.dart';
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import '../widgets/qr_scanner_widget.dart';
|
import '../widgets/qr_scanner_widget.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
|
||||||
|
|
||||||
/// Screen for scanning community QR codes to join communities.
|
/// Screen for scanning community QR codes to join communities.
|
||||||
///
|
///
|
||||||
@@ -35,16 +39,90 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
|||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: _isProcessing
|
body: _isProcessing
|
||||||
? const Center(child: CircularProgressIndicator())
|
? Container(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
: QrScannerWidget(
|
: QrScannerWidget(
|
||||||
onScanned: (data) => _handleScannedData(context, data),
|
onScanned: (data) => _handleScannedData(context, data),
|
||||||
validator: Community.isValidQrData,
|
validator: Community.isValidQrData,
|
||||||
onValidationFailed: (_) => _showInvalidQrError(context),
|
onValidationFailed: (_) => _showInvalidQrError(context),
|
||||||
instructions: context.l10n.community_scanInstructions,
|
instructions: context.l10n.community_scanInstructions,
|
||||||
|
overlay: _buildThemedOverlay(context),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildThemedOverlay(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Dark semi-transparent background with cutout
|
||||||
|
ColorFiltered(
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
Colors.black.withValues(alpha: 0.5),
|
||||||
|
BlendMode.srcOut,
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.black,
|
||||||
|
backgroundBlendMode: BlendMode.dstOut,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
height: 250,
|
||||||
|
width: 250,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Corner brackets on top
|
||||||
|
const ScannerCornerOverlay(
|
||||||
|
scanWindowSize: 250,
|
||||||
|
borderColor: MeshPalette.blue,
|
||||||
|
borderWidth: 2,
|
||||||
|
cornerLength: 24,
|
||||||
|
),
|
||||||
|
// Instructions pill below the scan window
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 250 + 24),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.72),
|
||||||
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.community_scanInstructions,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: MeshPalette.ink2,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleScannedData(BuildContext context, String data) async {
|
Future<void> _handleScannedData(BuildContext context, String data) async {
|
||||||
if (_isProcessing) return;
|
if (_isProcessing) return;
|
||||||
|
|
||||||
@@ -80,7 +158,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
|||||||
showDismissibleSnackBar(
|
showDismissibleSnackBar(
|
||||||
context,
|
context,
|
||||||
content: Text(context.l10n.community_invalidQrCode),
|
content: Text(context.l10n.community_invalidQrCode),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: MeshPalette.alert,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,29 +174,74 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
|||||||
showDismissibleSnackBar(
|
showDismissibleSnackBar(
|
||||||
context,
|
context,
|
||||||
content: Text(context.l10n.community_invalidQrCode),
|
content: Text(context.l10n.community_invalidQrCode),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: MeshPalette.warn,
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAlreadyMemberDialog(BuildContext context, Community community) {
|
void _showAlreadyMemberDialog(BuildContext context, Community community) {
|
||||||
showDialog(
|
showMeshSheet(
|
||||||
context: context,
|
context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (sheetContext) {
|
||||||
title: Text(context.l10n.community_alreadyMember),
|
final sheetScheme = Theme.of(sheetContext).colorScheme;
|
||||||
content: Text(
|
return Column(
|
||||||
context.l10n.community_alreadyMemberMessage(community.name),
|
mainAxisSize: MainAxisSize.min,
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
actions: [
|
children: [
|
||||||
TextButton(
|
BottomSheetHeader(title: context.l10n.community_alreadyMember),
|
||||||
onPressed: () {
|
Padding(
|
||||||
Navigator.pop(dialogContext);
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||||||
Navigator.pop(context);
|
child: Text(
|
||||||
},
|
context.l10n.community_alreadyMemberMessage(community.name),
|
||||||
child: Text(context.l10n.common_ok),
|
style: TextStyle(color: sheetScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MeshCard(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.groups,
|
||||||
|
color: MeshPalette.magenta,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
community.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'ID: ${community.shortCommunityId}...',
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11.5,
|
||||||
|
color: sheetScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: Text(context.l10n.common_ok),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,38 +250,51 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
|||||||
Community community,
|
Community community,
|
||||||
) async {
|
) async {
|
||||||
bool addPublicChannel = true;
|
bool addPublicChannel = true;
|
||||||
|
final completer = Completer<bool>();
|
||||||
|
|
||||||
final result = await showDialog<bool>(
|
await showMeshSheet<void>(
|
||||||
context: context,
|
context,
|
||||||
builder: (dialogContext) => StatefulBuilder(
|
builder: (sheetContext) => StatefulBuilder(
|
||||||
builder: (dialogContext, setDialogState) => AlertDialog(
|
builder: (sheetContext, setSheetState) {
|
||||||
title: Text(context.l10n.community_joinTitle),
|
final joinScheme = Theme.of(sheetContext).colorScheme;
|
||||||
content: Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(context.l10n.community_joinConfirmation(community.name)),
|
BottomSheetHeader(title: context.l10n.community_joinTitle),
|
||||||
const SizedBox(height: 16),
|
Padding(
|
||||||
Row(
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.community_joinConfirmation(community.name),
|
||||||
|
style: TextStyle(color: joinScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MeshCard(
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
AvatarCircle(
|
||||||
Icons.groups,
|
name: community.name,
|
||||||
color: Theme.of(dialogContext).colorScheme.primary,
|
icon: Icons.groups,
|
||||||
|
color: MeshPalette.magenta,
|
||||||
|
size: 44,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
community.name,
|
community.name,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'ID: ${community.shortCommunityId}...',
|
'ID: ${community.shortCommunityId}...',
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 12,
|
fontSize: 11.5,
|
||||||
color: Colors.grey[600],
|
color: joinScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -166,38 +302,59 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
CheckboxListTile(
|
|
||||||
value: addPublicChannel,
|
|
||||||
onChanged: (value) {
|
|
||||||
setDialogState(() {
|
|
||||||
addPublicChannel = value ?? true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
title: Text(context.l10n.community_addPublicChannel),
|
|
||||||
subtitle: Text(context.l10n.community_addPublicChannelHint),
|
|
||||||
controlAffinity: ListTileControlAffinity.leading,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(dialogContext, false),
|
|
||||||
child: Text(context.l10n.common_cancel),
|
|
||||||
),
|
),
|
||||||
FilledButton(
|
CheckboxListTile(
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
value: addPublicChannel,
|
||||||
child: Text(context.l10n.community_join),
|
onChanged: (value) {
|
||||||
|
setSheetState(() {
|
||||||
|
addPublicChannel = value ?? true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
title: Text(context.l10n.community_addPublicChannel),
|
||||||
|
subtitle: Text(context.l10n.community_addPublicChannelHint),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
completer.complete(false);
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
},
|
||||||
|
child: Text(context.l10n.common_cancel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
completer.complete(true);
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
},
|
||||||
|
child: Text(context.l10n.community_join),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result == true && context.mounted) {
|
// If sheet was dismissed without a button press, treat as cancel
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await completer.future;
|
||||||
|
|
||||||
|
if (result && context.mounted) {
|
||||||
await _joinCommunity(context, community, addPublicChannel);
|
await _joinCommunity(context, community, addPublicChannel);
|
||||||
} else if (context.mounted) {
|
} else if (context.mounted) {
|
||||||
// User cancelled - go back
|
// User cancelled - go back
|
||||||
@@ -231,7 +388,7 @@ class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
|||||||
showDismissibleSnackBar(
|
showDismissibleSnackBar(
|
||||||
context,
|
context,
|
||||||
content: Text(context.l10n.community_joined(community.name)),
|
content: Text(context.l10n.community_joined(community.name)),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: MeshPalette.signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return to previous screen
|
// Return to previous screen
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||||
import 'package:meshcore_open/models/companion_radio_stats.dart';
|
import 'package:meshcore_open/models/companion_radio_stats.dart';
|
||||||
import 'package:meshcore_open/l10n/l10n.dart';
|
import 'package:meshcore_open/l10n/l10n.dart';
|
||||||
|
import 'package:meshcore_open/theme/mesh_theme.dart';
|
||||||
|
import 'package:meshcore_open/widgets/mesh_ui.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class CompanionRadioStatsScreen extends StatefulWidget {
|
class CompanionRadioStatsScreen extends StatefulWidget {
|
||||||
@@ -49,6 +51,25 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _tile(String text, IconData icon, Color color) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: color),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: MeshTheme.mono(fontSize: 13, color: scheme.onSurface),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
@@ -85,44 +106,108 @@ class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
|
|||||||
valueListenable: connector.radioStatsNotifier,
|
valueListenable: connector.radioStatsNotifier,
|
||||||
builder: (context, stats, _) {
|
builder: (context, stats, _) {
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
children: [
|
children: [
|
||||||
if (stats != null) ...[
|
if (stats != null) ...[
|
||||||
Text(
|
const SectionHeader(
|
||||||
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
|
'Signal',
|
||||||
style: tt.titleMedium,
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
MeshCard(
|
||||||
Text(l10n.radioStats_lastRssi(stats.lastRssiDbm)),
|
margin: const EdgeInsets.symmetric(
|
||||||
Text(
|
horizontal: 16,
|
||||||
l10n.radioStats_lastSnr(
|
vertical: 4,
|
||||||
stats.lastSnrDb.toStringAsFixed(1),
|
),
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_tile(
|
||||||
|
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
|
||||||
|
Icons.noise_aware,
|
||||||
|
scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_tile(
|
||||||
|
l10n.radioStats_lastRssi(stats.lastRssiDbm),
|
||||||
|
Icons.wifi_tethering,
|
||||||
|
scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_tile(
|
||||||
|
l10n.radioStats_lastSnr(
|
||||||
|
stats.lastSnrDb.toStringAsFixed(1),
|
||||||
|
),
|
||||||
|
Icons.signal_cellular_alt,
|
||||||
|
MeshTheme.snrColor(
|
||||||
|
stats.lastSnrDb,
|
||||||
|
blocked: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(l10n.radioStats_txAir(stats.txAirSecs)),
|
const SectionHeader(
|
||||||
Text(l10n.radioStats_rxAir(stats.rxAirSecs)),
|
'Airtime',
|
||||||
const SizedBox(height: 16),
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
] else
|
),
|
||||||
Text(l10n.radioStats_waiting),
|
MeshCard(
|
||||||
const SizedBox(height: 16),
|
margin: const EdgeInsets.symmetric(
|
||||||
SizedBox(
|
horizontal: 16,
|
||||||
height: 200,
|
vertical: 4,
|
||||||
child: CustomPaint(
|
),
|
||||||
painter: _NoiseChartPainter(
|
padding: const EdgeInsets.all(4),
|
||||||
samples: List<double>.from(_noiseHistory),
|
child: Column(
|
||||||
colorScheme: scheme,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
textTheme: tt,
|
children: [
|
||||||
|
_tile(
|
||||||
|
l10n.radioStats_txAir(stats.txAirSecs),
|
||||||
|
Icons.upload,
|
||||||
|
MeshPalette.blue,
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_tile(
|
||||||
|
l10n.radioStats_rxAir(stats.rxAirSecs),
|
||||||
|
Icons.download,
|
||||||
|
MeshPalette.blue,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
l10n.radioStats_waiting,
|
||||||
|
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
SectionHeader(
|
||||||
|
l10n.radioStats_chartCaption,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _NoiseChartPainter(
|
||||||
|
samples: List<double>.from(_noiseHistory),
|
||||||
|
colorScheme: scheme,
|
||||||
|
textTheme: tt,
|
||||||
|
),
|
||||||
|
child: const SizedBox.expand(),
|
||||||
),
|
),
|
||||||
child: const SizedBox.expand(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
|
||||||
l10n.radioStats_chartCaption,
|
|
||||||
style: tt.bodySmall?.copyWith(
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../models/contact.dart';
|
|||||||
import '../l10n/contact_localization.dart';
|
import '../l10n/contact_localization.dart';
|
||||||
import '../models/contact_group.dart';
|
import '../models/contact_group.dart';
|
||||||
import '../services/ui_view_state_service.dart';
|
import '../services/ui_view_state_service.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
import '../utils/contact_search.dart';
|
import '../utils/contact_search.dart';
|
||||||
import '../storage/contact_group_store.dart';
|
import '../storage/contact_group_store.dart';
|
||||||
import '../utils/dialog_utils.dart';
|
import '../utils/dialog_utils.dart';
|
||||||
@@ -24,12 +25,12 @@ import '../utils/emoji_utils.dart';
|
|||||||
import '../utils/route_transitions.dart';
|
import '../utils/route_transitions.dart';
|
||||||
import '../widgets/list_filter_widget.dart';
|
import '../widgets/list_filter_widget.dart';
|
||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import '../widgets/quick_switch_bar.dart';
|
import '../widgets/quick_switch_bar.dart';
|
||||||
import '../widgets/repeater_login_dialog.dart';
|
import '../widgets/repeater_login_dialog.dart';
|
||||||
import '../widgets/room_login_dialog.dart';
|
import '../widgets/room_login_dialog.dart';
|
||||||
import '../widgets/sync_progress_overlay.dart';
|
import '../widgets/sync_progress_overlay.dart';
|
||||||
import '../widgets/unread_badge.dart';
|
import '../widgets/unread_badge.dart';
|
||||||
import '../helpers/contact_ui.dart';
|
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
import 'channels_screen.dart';
|
import 'channels_screen.dart';
|
||||||
import 'chat_screen.dart';
|
import 'chat_screen.dart';
|
||||||
@@ -472,12 +473,13 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showAddContactSheet(BuildContext context) {
|
void _showAddContactSheet(BuildContext context) {
|
||||||
showModalBottomSheet(
|
showMeshSheet(
|
||||||
context: context,
|
context,
|
||||||
builder: (sheetContext) => SafeArea(
|
builder: (sheetContext) => SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
BottomSheetHeader(title: context.l10n.contacts_title),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.paste),
|
leading: const Icon(Icons.paste),
|
||||||
title: Text(context.l10n.contacts_addContactFromClipboard),
|
title: Text(context.l10n.contacts_addContactFromClipboard),
|
||||||
@@ -499,6 +501,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -909,7 +912,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
final unreadCount = connector.getUnreadCountForContact(
|
final unreadCount = connector.getUnreadCountForContact(
|
||||||
contact,
|
contact,
|
||||||
);
|
);
|
||||||
return _ContactTile(
|
return _ContactTileEntrance(
|
||||||
|
index: index,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
lastSeen: _resolveLastSeen(contact),
|
lastSeen: _resolveLastSeen(contact),
|
||||||
unreadCount: unreadCount,
|
unreadCount: unreadCount,
|
||||||
@@ -1343,17 +1347,22 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
final isRoom = contact.type == advTypeRoom;
|
final isRoom = contact.type == advTypeRoom;
|
||||||
final isFavorite = contact.isFavorite;
|
final isFavorite = contact.isFavorite;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showMeshSheet(
|
||||||
context: context,
|
context,
|
||||||
builder: (sheetContext) => SafeArea(
|
builder: (sheetContext) => SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
BottomSheetHeader(
|
||||||
|
title: contact.name,
|
||||||
|
subtitle: contact.typeLabel(context.l10n),
|
||||||
|
),
|
||||||
if (isRepeater) ...[
|
if (isRepeater) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.radar, color: Colors.green),
|
leading: Icon(Icons.radar, color: MeshPalette.signal),
|
||||||
title: Text(context.l10n.contacts_ping),
|
title: Text(context.l10n.contacts_ping),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
final hw = context
|
final hw = context
|
||||||
.read<MeshCoreConnector>()
|
.read<MeshCoreConnector>()
|
||||||
.pathHashByteWidth;
|
.pathHashByteWidth;
|
||||||
@@ -1371,7 +1380,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.cell_tower, color: Colors.orange),
|
leading: Icon(Icons.cell_tower, color: MeshPalette.warn),
|
||||||
title: Text(context.l10n.contacts_manageRepeater),
|
title: Text(context.l10n.contacts_manageRepeater),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
@@ -1380,9 +1389,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
),
|
),
|
||||||
] else if (isRoom) ...[
|
] else if (isRoom) ...[
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.radar, color: Colors.green),
|
leading: Icon(Icons.radar, color: MeshPalette.signal),
|
||||||
title: Text(context.l10n.contacts_pathTrace),
|
title: Text(context.l10n.contacts_pathTrace),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
final hw = context
|
final hw = context
|
||||||
.read<MeshCoreConnector>()
|
.read<MeshCoreConnector>()
|
||||||
.pathHashByteWidth;
|
.pathHashByteWidth;
|
||||||
@@ -1405,7 +1415,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.room, color: Colors.blue),
|
leading: Icon(Icons.meeting_room, color: MeshPalette.blue),
|
||||||
title: Text(context.l10n.contacts_roomLogin),
|
title: Text(context.l10n.contacts_roomLogin),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
@@ -1413,10 +1423,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(
|
leading: Icon(Icons.room_preferences, color: MeshPalette.warn),
|
||||||
Icons.room_preferences,
|
|
||||||
color: Colors.orange,
|
|
||||||
),
|
|
||||||
title: Text(context.l10n.room_management),
|
title: Text(context.l10n.room_management),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
@@ -1430,9 +1437,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
] else ...[
|
] else ...[
|
||||||
if (contact.pathLength > 0)
|
if (contact.pathLength > 0)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.radar, color: Colors.green),
|
leading: Icon(Icons.radar, color: MeshPalette.signal),
|
||||||
title: Text(context.l10n.contacts_chatTraceRoute),
|
title: Text(context.l10n.contacts_chatTraceRoute),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
final hw = context
|
final hw = context
|
||||||
.read<MeshCoreConnector>()
|
.read<MeshCoreConnector>()
|
||||||
.pathHashByteWidth;
|
.pathHashByteWidth;
|
||||||
@@ -1456,7 +1464,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
isFavorite ? Icons.star : Icons.star_border,
|
isFavorite ? Icons.star : Icons.star_border,
|
||||||
color: Colors.amber[700],
|
color: MeshPalette.warn,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
isFavorite
|
isFavorite
|
||||||
@@ -1501,6 +1509,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
_confirmDelete(context, connector, contact);
|
_confirmDelete(context, connector, contact);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1555,82 +1564,173 @@ class _ContactTile extends StatelessWidget {
|
|||||||
required this.onLongPress,
|
required this.onLongPress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
/// Node-type avatar color per design language.
|
||||||
Widget build(BuildContext context) {
|
Color _avatarColor() {
|
||||||
return GestureDetector(
|
switch (contact.type) {
|
||||||
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
|
case advTypeRepeater:
|
||||||
child: ListTile(
|
return MeshPalette.warn;
|
||||||
leading: CircleAvatar(
|
case advTypeRoom:
|
||||||
backgroundColor: contactTypeColor(contact.type),
|
return MeshPalette.magenta;
|
||||||
child: _buildContactAvatar(contact),
|
case advTypeSensor:
|
||||||
),
|
return const Color(0xFF4ACCC4); // teal
|
||||||
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
default:
|
||||||
subtitle: Text(
|
return MeshPalette.blue; // chat — AvatarCircle handles deterministic hue
|
||||||
contact.pathLabel(context.l10n),
|
}
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
// Clamp text scaling in trailing section to prevent overflow while
|
|
||||||
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
|
||||||
trailing: MediaQuery(
|
|
||||||
data: MediaQuery.of(context).copyWith(
|
|
||||||
textScaler: TextScaler.linear(
|
|
||||||
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 96,
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
if (unreadCount > 0) ...[
|
|
||||||
UnreadBadge(count: unreadCount),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
],
|
|
||||||
Text(
|
|
||||||
_formatLastSeen(context, lastSeen),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
textAlign: TextAlign.right,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (isFavorite)
|
|
||||||
Icon(Icons.star, size: 14, color: Colors.amber[700]),
|
|
||||||
if (isFavorite && contact.hasLocation)
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
if (contact.hasLocation)
|
|
||||||
Icon(
|
|
||||||
Icons.location_on,
|
|
||||||
size: 14,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: onTap,
|
|
||||||
onLongPress: onLongPress,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContactAvatar(Contact contact) {
|
/// Node-type avatar icon. Returns null for chat nodes so AvatarCircle shows initials.
|
||||||
final emoji = firstEmoji(contact.name);
|
IconData? _avatarIcon() {
|
||||||
if (emoji != null) {
|
switch (contact.type) {
|
||||||
return Text(emoji, style: const TextStyle(fontSize: 18));
|
case advTypeRepeater:
|
||||||
|
return Icons.cell_tower;
|
||||||
|
case advTypeRoom:
|
||||||
|
return Icons.meeting_room;
|
||||||
|
case advTypeSensor:
|
||||||
|
return Icons.sensors;
|
||||||
|
default:
|
||||||
|
return null; // chat uses initials
|
||||||
}
|
}
|
||||||
return Icon(contactTypeIcon(contact.type), color: Colors.white, size: 20);
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final emoji = firstEmoji(contact.name);
|
||||||
|
final isChat = contact.type == advTypeChat;
|
||||||
|
final pathLen = contact.pathBytesForDisplay.length;
|
||||||
|
final isDirect = contact.pathLength >= 0;
|
||||||
|
final hasPath = pathLen > 0 || contact.pathLength == 0;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null,
|
||||||
|
child: MeshCard(
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Avatar
|
||||||
|
if (emoji != null)
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: scheme.surfaceContainerHigh,
|
||||||
|
border: Border.all(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(emoji, style: const TextStyle(fontSize: 20)),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
AvatarCircle(
|
||||||
|
name: contact.name,
|
||||||
|
size: 42,
|
||||||
|
color: isChat ? null : _avatarColor(),
|
||||||
|
icon: _avatarIcon(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Main content
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Name row + route chip
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
contact.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: unreadCount > 0
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w500,
|
||||||
|
fontSize: 15,
|
||||||
|
color: scheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isFavorite) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(Icons.star, size: 13, color: MeshPalette.warn),
|
||||||
|
],
|
||||||
|
if (contact.hasLocation) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(
|
||||||
|
Icons.location_on,
|
||||||
|
size: 13,
|
||||||
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.55),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
// Path / subtitle row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
contact.pathLabel(context.l10n),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasPath) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
RouteChip(
|
||||||
|
isDirect: isDirect,
|
||||||
|
hops: isDirect ? contact.pathLength : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
// Trailing: time + unread badge
|
||||||
|
// Clamp text scale to prevent overflow in trailing section.
|
||||||
|
MediaQuery(
|
||||||
|
data: MediaQuery.of(context).copyWith(
|
||||||
|
textScaler: TextScaler.linear(
|
||||||
|
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (unreadCount > 0) ...[
|
||||||
|
UnreadBadge(count: unreadCount),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
_formatLastSeen(context, lastSeen),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11,
|
||||||
|
color: unreadCount > 0
|
||||||
|
? MeshPalette.blue
|
||||||
|
: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
|
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
|
||||||
@@ -1655,3 +1755,39 @@ class _ContactTile extends StatelessWidget {
|
|||||||
: context.l10n.contacts_lastSeenDaysAgo(days);
|
: context.l10n.contacts_lastSeenDaysAgo(days);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrap each contact tile with staggered entrance.
|
||||||
|
class _ContactTileEntrance extends StatelessWidget {
|
||||||
|
final int index;
|
||||||
|
final Contact contact;
|
||||||
|
final DateTime lastSeen;
|
||||||
|
final int unreadCount;
|
||||||
|
final bool isFavorite;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
|
const _ContactTileEntrance({
|
||||||
|
required this.index,
|
||||||
|
required this.contact,
|
||||||
|
required this.lastSeen,
|
||||||
|
required this.unreadCount,
|
||||||
|
required this.isFavorite,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListEntrance(
|
||||||
|
index: index,
|
||||||
|
child: _ContactTile(
|
||||||
|
contact: contact,
|
||||||
|
lastSeen: lastSeen,
|
||||||
|
unreadCount: unreadCount,
|
||||||
|
isFavorite: isFavorite,
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+189
-109
@@ -7,12 +7,14 @@ import 'package:provider/provider.dart';
|
|||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../l10n/contact_localization.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
import '../utils/contact_search.dart';
|
import '../utils/contact_search.dart';
|
||||||
import '../utils/platform_info.dart';
|
import '../utils/platform_info.dart';
|
||||||
import '../widgets/app_bar.dart';
|
import '../widgets/app_bar.dart';
|
||||||
import '../widgets/list_filter_widget.dart';
|
import '../widgets/list_filter_widget.dart';
|
||||||
import '../helpers/contact_ui.dart';
|
import '../widgets/mesh_ui.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
|
|
||||||
enum DiscoverySortOption { lastSeen, name, type }
|
enum DiscoverySortOption { lastSeen, name, type }
|
||||||
@@ -47,6 +49,34 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
: contact.lastSeen;
|
: contact.lastSeen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Node-type avatar color per design language.
|
||||||
|
Color _avatarColor(int type) {
|
||||||
|
switch (type) {
|
||||||
|
case advTypeRepeater:
|
||||||
|
return MeshPalette.warn;
|
||||||
|
case advTypeRoom:
|
||||||
|
return MeshPalette.magenta;
|
||||||
|
case advTypeSensor:
|
||||||
|
return const Color(0xFF4ACCC4); // teal
|
||||||
|
default:
|
||||||
|
return MeshPalette.blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Node-type avatar icon; null = show initials for chat nodes.
|
||||||
|
IconData? _avatarIcon(int type) {
|
||||||
|
switch (type) {
|
||||||
|
case advTypeRepeater:
|
||||||
|
return Icons.cell_tower;
|
||||||
|
case advTypeRoom:
|
||||||
|
return Icons.meeting_room;
|
||||||
|
case advTypeSensor:
|
||||||
|
return Icons.sensors;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
@@ -93,121 +123,167 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_buildFilters(filteredAndSorted, connector),
|
_buildFilters(filteredAndSorted, connector),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: discoveredContacts.isEmpty
|
child: AnimatedSwitcher(
|
||||||
? Center(child: Text(l10n.contacts_noContacts))
|
duration: const Duration(milliseconds: 220),
|
||||||
: filteredAndSorted.isEmpty
|
child: discoveredContacts.isEmpty
|
||||||
? Center(child: Text(l10n.discoveredContacts_noMatching))
|
? Center(
|
||||||
: ListView.builder(
|
key: const ValueKey('empty_all'),
|
||||||
itemCount: filteredAndSorted.length,
|
child: Text(l10n.contacts_noContacts),
|
||||||
itemBuilder: (context, index) {
|
)
|
||||||
final contact = filteredAndSorted[index];
|
: filteredAndSorted.isEmpty
|
||||||
final tile = ListTile(
|
? Center(
|
||||||
leading: CircleAvatar(
|
key: const ValueKey('empty_filtered'),
|
||||||
backgroundColor: contactTypeColor(contact.type),
|
child: Text(l10n.discoveredContacts_noMatching),
|
||||||
child: Icon(
|
)
|
||||||
contactTypeIcon(contact.type),
|
: ListView.builder(
|
||||||
color: Colors.white,
|
key: const ValueKey('list'),
|
||||||
size: 20,
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
),
|
itemCount: filteredAndSorted.length,
|
||||||
),
|
itemBuilder: (context, index) {
|
||||||
title: Text(
|
final contact = filteredAndSorted[index];
|
||||||
|
final tile = _buildDiscoveryTile(
|
||||||
|
context,
|
||||||
|
contact,
|
||||||
|
connector,
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
if (PlatformInfo.isDesktop) {
|
||||||
|
return GestureDetector(
|
||||||
|
onSecondaryTapUp: (_) =>
|
||||||
|
_showContactContextMenu(contact, connector),
|
||||||
|
child: tile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tile;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDiscoveryTile(
|
||||||
|
BuildContext context,
|
||||||
|
Contact contact,
|
||||||
|
MeshCoreConnector connector,
|
||||||
|
int index,
|
||||||
|
) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final isChat = contact.type == advTypeChat;
|
||||||
|
|
||||||
|
return ListEntrance(
|
||||||
|
index: index,
|
||||||
|
child: MeshCard(
|
||||||
|
onTap: () {
|
||||||
|
connector.importDiscoveredContact(contact);
|
||||||
|
showDismissibleSnackBar(
|
||||||
|
context,
|
||||||
|
content: Text(
|
||||||
|
context.l10n.discoveredContacts_contactAdded,
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: context.l10n.common_undo,
|
||||||
|
onPressed: () => connector.removeContact(contact),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onLongPress: () => _showContactContextMenu(contact, connector),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
AvatarCircle(
|
||||||
|
name: contact.name,
|
||||||
|
size: 42,
|
||||||
|
color: isChat ? null : _avatarColor(contact.type),
|
||||||
|
icon: _avatarIcon(contact.type),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Name + type chip
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
contact.name,
|
contact.name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
StatusChip(
|
||||||
|
label: contact.typeLabel(context.l10n).toUpperCase(),
|
||||||
|
color: _avatarColor(contact.type),
|
||||||
|
icon: _avatarIcon(contact.type),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
// Short pub key
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
contact.shortPubKeyHex,
|
contact.shortPubKeyHex,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
style: MeshTheme.mono(
|
||||||
// Clamp text scaling in trailing section to prevent overflow while
|
fontSize: 11,
|
||||||
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
color: scheme.onSurfaceVariant,
|
||||||
trailing: MediaQuery(
|
|
||||||
data: MediaQuery.of(context).copyWith(
|
|
||||||
textScaler: TextScaler.linear(
|
|
||||||
MediaQuery.textScalerOf(
|
|
||||||
context,
|
|
||||||
).scale(1.0).clamp(1.0, 1.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 120,
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_formatLastSeen(
|
|
||||||
context,
|
|
||||||
_resolveLastSeen(contact),
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
textAlign: TextAlign.right,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (contact.hasLocation)
|
|
||||||
Icon(
|
|
||||||
Icons.location_on,
|
|
||||||
size: 14,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurfaceVariant
|
|
||||||
.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
if (contact.rawPacket != null)
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
if (contact.rawPacket != null)
|
|
||||||
Icon(
|
|
||||||
Icons.cell_tower,
|
|
||||||
size: 14,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onSurfaceVariant
|
|
||||||
.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () {
|
),
|
||||||
connector.importDiscoveredContact(contact);
|
if (contact.hasLocation) ...[
|
||||||
showDismissibleSnackBar(
|
const SizedBox(width: 6),
|
||||||
context,
|
Icon(
|
||||||
content: Text(
|
Icons.location_on,
|
||||||
context.l10n.discoveredContacts_contactAdded,
|
size: 13,
|
||||||
),
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.55),
|
||||||
action: SnackBarAction(
|
),
|
||||||
label: context.l10n.common_undo,
|
],
|
||||||
onPressed: () => connector.removeContact(contact),
|
if (contact.rawPacket != null) ...[
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
);
|
Icon(
|
||||||
},
|
Icons.cell_tower,
|
||||||
onLongPress: () =>
|
size: 13,
|
||||||
_showContactContextMenu(contact, connector),
|
color: scheme.onSurfaceVariant.withValues(alpha: 0.55),
|
||||||
);
|
),
|
||||||
if (PlatformInfo.isDesktop) {
|
],
|
||||||
return GestureDetector(
|
],
|
||||||
onSecondaryTapUp: (_) =>
|
|
||||||
_showContactContextMenu(contact, connector),
|
|
||||||
child: tile,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return tile;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
// Last seen time
|
||||||
|
MediaQuery(
|
||||||
|
data: MediaQuery.of(context).copyWith(
|
||||||
|
textScaler: TextScaler.linear(
|
||||||
|
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_formatLastSeen(context, _resolveLastSeen(contact)),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -216,15 +292,18 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
Contact contact,
|
Contact contact,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
) async {
|
) async {
|
||||||
final action = await showModalBottomSheet<String>(
|
final action = await showMeshSheet<String>(
|
||||||
context: context,
|
context,
|
||||||
showDragHandle: true,
|
|
||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
BottomSheetHeader(
|
||||||
|
title: contact.name,
|
||||||
|
subtitle: contact.typeLabel(l10n),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.copy),
|
leading: const Icon(Icons.copy),
|
||||||
title: Text(l10n.discoveredContacts_copyContact),
|
title: Text(l10n.discoveredContacts_copyContact),
|
||||||
@@ -235,6 +314,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
title: Text(l10n.discoveredContacts_deleteContact),
|
title: Text(l10n.discoveredContacts_deleteContact),
|
||||||
onTap: () => Navigator.of(sheetContext).pop('delete_contact'),
|
onTap: () => Navigator.of(sheetContext).pop('delete_contact'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+166
-128
@@ -10,6 +10,9 @@ import '../services/app_settings_service.dart';
|
|||||||
import '../services/map_tile_cache_service.dart';
|
import '../services/map_tile_cache_service.dart';
|
||||||
import '../widgets/adaptive_app_bar_title.dart';
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
|
import '../widgets/themed_map_tile_layer.dart';
|
||||||
|
|
||||||
class MapCacheScreen extends StatefulWidget {
|
class MapCacheScreen extends StatefulWidget {
|
||||||
const MapCacheScreen({super.key});
|
const MapCacheScreen({super.key});
|
||||||
@@ -76,27 +79,34 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
return Positioned(
|
return Positioned(
|
||||||
top: 12,
|
top: 12,
|
||||||
left: 12,
|
left: 12,
|
||||||
child: Card(
|
child: DecoratedBox(
|
||||||
elevation: 4,
|
decoration: BoxDecoration(
|
||||||
child: Column(
|
color: MeshPalette.bg1.withValues(alpha: 0.90),
|
||||||
mainAxisSize: MainAxisSize.min,
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
||||||
children: [
|
border: Border.all(color: MeshPalette.line2),
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.add),
|
child: ClipRRect(
|
||||||
tooltip: context.l10n.map_zoomIn,
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
||||||
onPressed: () => _zoomMapBy(1),
|
child: Column(
|
||||||
),
|
mainAxisSize: MainAxisSize.min,
|
||||||
IconButton(
|
children: [
|
||||||
icon: const Icon(Icons.remove),
|
IconButton(
|
||||||
tooltip: context.l10n.map_zoomOut,
|
icon: const Icon(Icons.add),
|
||||||
onPressed: () => _zoomMapBy(-1),
|
tooltip: context.l10n.map_zoomIn,
|
||||||
),
|
onPressed: () => _zoomMapBy(1),
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.my_location),
|
IconButton(
|
||||||
tooltip: context.l10n.map_centerMap,
|
icon: const Icon(Icons.remove),
|
||||||
onPressed: _resetMapView,
|
tooltip: context.l10n.map_zoomOut,
|
||||||
),
|
onPressed: () => _zoomMapBy(-1),
|
||||||
],
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.my_location),
|
||||||
|
tooltip: context.l10n.map_centerMap,
|
||||||
|
onPressed: _resetMapView,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -281,6 +291,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
final tileCache = context.read<MapTileCacheService>();
|
final tileCache = context.read<MapTileCacheService>();
|
||||||
final selectedBounds = _selectedBounds;
|
final selectedBounds = _selectedBounds;
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
|
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
|
||||||
final progressValue = _estimatedTiles == 0
|
final progressValue = _estimatedTiles == 0
|
||||||
? 0.0
|
? 0.0
|
||||||
@@ -318,13 +329,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
TileLayer(
|
ThemedMapTileLayer(tileCache: tileCache),
|
||||||
urlTemplate: kMapTileUrlTemplate,
|
|
||||||
tileProvider: tileCache.tileProvider,
|
|
||||||
userAgentPackageName:
|
|
||||||
MapTileCacheService.userAgentPackageName,
|
|
||||||
maxZoom: 19,
|
|
||||||
),
|
|
||||||
if (selectedBounds != null)
|
if (selectedBounds != null)
|
||||||
PolygonLayer(
|
PolygonLayer(
|
||||||
polygons: [
|
polygons: [
|
||||||
@@ -342,14 +347,25 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
Positioned(
|
Positioned(
|
||||||
top: 12,
|
top: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
child: Card(
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: MeshPalette.bg1.withValues(alpha: 0.93),
|
||||||
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
||||||
|
border: Border.all(color: MeshPalette.line2),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
selectedBounds == null
|
selectedBounds == null
|
||||||
? l10n.mapCache_noAreaSelected
|
? l10n.mapCache_noAreaSelected
|
||||||
: _formatBounds(selectedBounds, l10n),
|
: _formatBounds(selectedBounds, l10n),
|
||||||
style: const TextStyle(fontSize: 12),
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11,
|
||||||
|
color: MeshPalette.ink2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -359,111 +375,133 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
|||||||
),
|
),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: DecoratedBox(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
decoration: BoxDecoration(
|
||||||
child: Column(
|
color: scheme.surfaceContainerLow,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
border: Border(top: BorderSide(color: scheme.outlineVariant)),
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
child: Padding(
|
||||||
Text(
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||||
l10n.mapCache_cacheArea,
|
child: Column(
|
||||||
style: const TextStyle(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontWeight: FontWeight.bold,
|
mainAxisSize: MainAxisSize.min,
|
||||||
fontSize: 16,
|
children: [
|
||||||
|
SectionHeader(
|
||||||
|
l10n.mapCache_cacheArea,
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 12, 0, 8),
|
||||||
),
|
),
|
||||||
),
|
Row(
|
||||||
const SizedBox(height: 8),
|
children: [
|
||||||
Row(
|
Expanded(
|
||||||
children: [
|
child: ElevatedButton.icon(
|
||||||
Expanded(
|
icon: const Icon(Icons.crop_free),
|
||||||
child: ElevatedButton.icon(
|
label: Text(l10n.mapCache_useCurrentView),
|
||||||
icon: const Icon(Icons.crop_free),
|
onPressed: _isDownloading
|
||||||
label: Text(l10n.mapCache_useCurrentView),
|
? null
|
||||||
onPressed: _isDownloading ? null : _setBoundsFromView,
|
: _setBoundsFromView,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
const SizedBox(width: 12),
|
TextButton(
|
||||||
TextButton(
|
|
||||||
onPressed: _isDownloading || selectedBounds == null
|
|
||||||
? null
|
|
||||||
: _clearBounds,
|
|
||||||
child: Text(l10n.common_clear),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
l10n.mapCache_zoomRange,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RangeSlider(
|
|
||||||
values: RangeValues(
|
|
||||||
_minZoom.toDouble(),
|
|
||||||
_maxZoom.toDouble(),
|
|
||||||
),
|
|
||||||
min: 3,
|
|
||||||
max: 18,
|
|
||||||
divisions: 15,
|
|
||||||
labels: RangeLabels('$_minZoom', '$_maxZoom'),
|
|
||||||
onChanged: _isDownloading
|
|
||||||
? null
|
|
||||||
: (values) {
|
|
||||||
setState(() {
|
|
||||||
_minZoom = values.start.round();
|
|
||||||
_maxZoom = values.end.round();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onChangeEnd: _isDownloading
|
|
||||||
? null
|
|
||||||
: (_) {
|
|
||||||
_saveZoomRange();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Text(l10n.mapCache_estimatedTiles(_estimatedTiles)),
|
|
||||||
if (_isDownloading) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
LinearProgressIndicator(value: progressValue),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
l10n.mapCache_downloadedTiles(
|
|
||||||
_completedTiles,
|
|
||||||
_estimatedTiles,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
icon: const Icon(Icons.download),
|
|
||||||
label: Text(l10n.mapCache_downloadTilesButton),
|
|
||||||
onPressed: _isDownloading || selectedBounds == null
|
onPressed: _isDownloading || selectedBounds == null
|
||||||
? null
|
? null
|
||||||
: _startDownload,
|
: _clearBounds,
|
||||||
|
child: Text(l10n.common_clear),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SectionHeader(
|
||||||
|
l10n.mapCache_zoomRange,
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
|
||||||
|
),
|
||||||
|
RangeSlider(
|
||||||
|
values: RangeValues(
|
||||||
|
_minZoom.toDouble(),
|
||||||
|
_maxZoom.toDouble(),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
min: 3,
|
||||||
OutlinedButton(
|
max: 18,
|
||||||
onPressed: _isDownloading ? null : _clearCache,
|
divisions: 15,
|
||||||
child: Text(l10n.mapCache_clearCacheButton),
|
labels: RangeLabels('$_minZoom', '$_maxZoom'),
|
||||||
),
|
onChanged: _isDownloading
|
||||||
],
|
? null
|
||||||
),
|
: (values) {
|
||||||
if (_failedTiles > 0 && !_isDownloading)
|
setState(() {
|
||||||
Padding(
|
_minZoom = values.start.round();
|
||||||
padding: const EdgeInsets.only(top: 8),
|
_maxZoom = values.end.round();
|
||||||
child: Text(
|
});
|
||||||
l10n.mapCache_failedDownloads(_failedTiles),
|
},
|
||||||
style: TextStyle(
|
onChangeEnd: _isDownloading
|
||||||
color: Theme.of(context).colorScheme.error,
|
? null
|
||||||
),
|
: (_) {
|
||||||
|
_saveZoomRange();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
l10n.mapCache_estimatedTiles(_estimatedTiles),
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 12,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (_isDownloading) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: progressValue,
|
||||||
|
color: MeshPalette.blue,
|
||||||
|
backgroundColor: scheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l10n.mapCache_downloadedTiles(
|
||||||
|
_completedTiles,
|
||||||
|
_estimatedTiles,
|
||||||
|
),
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 12,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: Text(l10n.mapCache_downloadTilesButton),
|
||||||
|
onPressed: _isDownloading || selectedBounds == null
|
||||||
|
? null
|
||||||
|
: _startDownload,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
OutlinedButton(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: MeshPalette.alert,
|
||||||
|
side: const BorderSide(
|
||||||
|
color: MeshPalette.alertLine,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: _isDownloading ? null : _clearCache,
|
||||||
|
child: Text(l10n.mapCache_clearCacheButton),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_failedTiles > 0 && !_isDownloading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
l10n.mapCache_failedDownloads(_failedTiles),
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 12,
|
||||||
|
color: MeshPalette.alert,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+1711
-643
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_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../services/repeater_command_service.dart';
|
import '../services/repeater_command_service.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import '../widgets/routing_sheet.dart';
|
import '../widgets/routing_sheet.dart';
|
||||||
import '../widgets/snr_indicator.dart';
|
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
|
|
||||||
class NeighborsScreen extends StatefulWidget {
|
class NeighborsScreen extends StatefulWidget {
|
||||||
@@ -321,7 +322,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
|||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: _loadNeighbors,
|
onRefresh: _loadNeighbors,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||||
children: [
|
children: [
|
||||||
if (!_isLoaded &&
|
if (!_isLoaded &&
|
||||||
!_hasData &&
|
!_hasData &&
|
||||||
@@ -330,9 +331,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
|||||||
if (_isLoaded ||
|
if (_isLoaded ||
|
||||||
_hasData &&
|
_hasData &&
|
||||||
!(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
|
!(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
|
||||||
_buildNeighborsInfoCard(
|
_buildNeighborsList(connector),
|
||||||
"${l10n.repeater_neighbors} - $_neighborCount",
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -340,81 +339,100 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNeighborsInfoCard(String title) {
|
Widget _buildNeighborsList(MeshCoreConnector connector) {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final l10n = context.l10n;
|
||||||
return Card(
|
return Column(
|
||||||
child: Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.all(16),
|
children: [
|
||||||
child: Column(
|
SectionHeader(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
'${l10n.repeater_neighbors} — $_neighborCount',
|
||||||
children: [
|
padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
|
||||||
Row(
|
),
|
||||||
|
for (var i = 0; i < _parsedNeighbors!.length; i++)
|
||||||
|
ListEntrance(
|
||||||
|
index: i,
|
||||||
|
child: _buildNeighborRow(
|
||||||
|
_parsedNeighbors![i],
|
||||||
|
connector.currentSf,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNeighborRow(Map<String, dynamic> data, int? spreadingFactor) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final Contact? contact = data['contact'] as Contact?;
|
||||||
|
final double snr = data['snr'] as double;
|
||||||
|
final int lastHeardSeconds = data['lastHeard'] as int;
|
||||||
|
|
||||||
|
final name = contact != null
|
||||||
|
? contact.name
|
||||||
|
: l10n.neighbors_unknownContact(
|
||||||
|
'<${pubKeyToHex(data['publicKey'] as Uint8List)}>',
|
||||||
|
);
|
||||||
|
|
||||||
|
final snrColor = MeshTheme.snrColor(snr, blocked: false);
|
||||||
|
final heardLabel = l10n.neighbors_heardAgo(
|
||||||
|
fmtDuration(lastHeardSeconds + 0.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
return MeshCard(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
AvatarCircle(
|
||||||
|
name: name,
|
||||||
|
size: 40,
|
||||||
|
color: contact != null ? MeshPalette.warn : scheme.onSurfaceVariant,
|
||||||
|
icon: contact != null ? Icons.cell_tower : Icons.device_unknown,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
|
||||||
Icons.info_outline,
|
|
||||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
Text(
|
||||||
title,
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
fontWeight: FontWeight.w500,
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
heardLabel,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(),
|
),
|
||||||
for (final entry in _parsedNeighbors!.asMap().entries)
|
const SizedBox(width: 10),
|
||||||
_buildInfoRow(
|
Column(
|
||||||
entry.value['contact'] != null
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
? entry.value['contact'].name
|
mainAxisSize: MainAxisSize.min,
|
||||||
: context.l10n.neighbors_unknownContact(
|
children: [
|
||||||
"<${pubKeyToHex(entry.value['publicKey'])}>",
|
SignalBars(snr: snr, height: 16),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
context.l10n.neighbors_heardAgo(
|
Text(
|
||||||
fmtDuration(entry.value['lastHeard'] + 0.0),
|
'${snr.toStringAsFixed(1)} dB',
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: snrColor,
|
||||||
),
|
),
|
||||||
entry.value['snr'],
|
|
||||||
connector.currentSf,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInfoRow(
|
|
||||||
String label,
|
|
||||||
String value,
|
|
||||||
double snr,
|
|
||||||
int? spreadingFactor,
|
|
||||||
) {
|
|
||||||
final snrUi = snrUiFromSNR(snr, spreadingFactor);
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
subtitle: Text(value),
|
|
||||||
trailing: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(snrUi.icon, color: snrUi.color, size: 18.0),
|
|
||||||
Text(
|
|
||||||
snrUi.text,
|
|
||||||
style: TextStyle(fontSize: 10, color: snrUi.color),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
+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/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../l10n/contact_localization.dart';
|
import '../l10n/contact_localization.dart';
|
||||||
import '../services/app_settings_service.dart';
|
import '../services/app_settings_service.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import 'repeater_status_screen.dart';
|
import 'repeater_status_screen.dart';
|
||||||
import 'repeater_cli_screen.dart';
|
import 'repeater_cli_screen.dart';
|
||||||
import 'repeater_settings_screen.dart';
|
import 'repeater_settings_screen.dart';
|
||||||
@@ -26,189 +29,157 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final settingsService = context.watch<AppSettingsService>();
|
final settingsService = context.watch<AppSettingsService>();
|
||||||
final chemistry = settingsService.batteryChemistryForRepeater(
|
final chemistry = settingsService.batteryChemistryForRepeater(
|
||||||
repeater.publicKeyHex,
|
repeater.publicKeyHex,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Column(
|
title: Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
repeater.type == advTypeRepeater
|
||||||
mainAxisSize: MainAxisSize.min,
|
? (isAdmin ? l10n.repeater_management : l10n.repeater_guest)
|
||||||
children: [
|
: (isAdmin ? l10n.room_management : l10n.room_guest),
|
||||||
if (isAdmin)
|
|
||||||
Text(
|
|
||||||
repeater.type == advTypeRepeater
|
|
||||||
? l10n.repeater_management
|
|
||||||
: l10n.room_management,
|
|
||||||
),
|
|
||||||
if (!isAdmin)
|
|
||||||
Text(
|
|
||||||
repeater.type == advTypeRepeater
|
|
||||||
? l10n.repeater_guest
|
|
||||||
: l10n.room_guest,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
repeater.name,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
children: [
|
children: [
|
||||||
// Repeater info card
|
// ── Identity card ─────────────────────────────────────────────
|
||||||
Card(
|
Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||||
padding: const EdgeInsets.all(16),
|
child: MeshCard(
|
||||||
child: Column(
|
margin: EdgeInsets.zero,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
AvatarCircle(
|
||||||
radius: 40,
|
name: repeater.name,
|
||||||
backgroundColor: Theme.of(
|
size: 52,
|
||||||
context,
|
color: MeshPalette.warn,
|
||||||
).colorScheme.tertiaryContainer,
|
icon: Icons.cell_tower,
|
||||||
child: Icon(
|
|
||||||
Icons.cell_tower,
|
|
||||||
size: 40,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onTertiaryContainer,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(width: 16),
|
||||||
Text(
|
Expanded(
|
||||||
repeater.name,
|
child: Column(
|
||||||
style: const TextStyle(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
repeater.shortPubKeyHex,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
repeater.pathLabel(context.l10n),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (repeater.hasLocation) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
|
||||||
Icons.location_on,
|
|
||||||
size: 14,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
Text(
|
||||||
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
|
repeater.name,
|
||||||
style: TextStyle(
|
style: Theme.of(context)
|
||||||
fontSize: 12,
|
.textTheme
|
||||||
color: Theme.of(
|
.titleMedium
|
||||||
context,
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
).colorScheme.onSurfaceVariant,
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
repeater.shortPubKeyHex,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
repeater.pathLabel(l10n),
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodySmall
|
||||||
|
?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
if (repeater.hasLocation) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.location_on,
|
||||||
|
size: 12,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${repeater.latitude?.toStringAsFixed(4)}, '
|
||||||
|
'${repeater.longitude?.toStringAsFixed(4)}',
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 10,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
StatusChip(
|
||||||
|
label: isAdmin ? 'ADMIN' : 'GUEST',
|
||||||
|
color: isAdmin ? MeshPalette.blue : scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
if (isAdmin)
|
// ── Battery chemistry (admin only) ─────────────────────────────
|
||||||
Card(
|
if (isAdmin) ...[
|
||||||
child: Padding(
|
SectionHeader(l10n.appSettings_batteryChemistry),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
MeshCard(
|
||||||
child: Column(
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.fromLTRB(14, 10, 14, 14),
|
||||||
children: [
|
child: DropdownButtonFormField<String>(
|
||||||
Row(
|
initialValue: chemistry,
|
||||||
children: [
|
isExpanded: true,
|
||||||
const Icon(Icons.battery_full),
|
decoration: InputDecoration(
|
||||||
const SizedBox(width: 10),
|
prefixIcon: const Icon(Icons.battery_full, size: 18),
|
||||||
Expanded(
|
labelText: l10n.appSettings_batteryChemistry,
|
||||||
child: Text(
|
|
||||||
l10n.appSettings_batteryChemistry,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
DropdownButtonFormField<String>(
|
|
||||||
initialValue: chemistry,
|
|
||||||
isExpanded: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
border: UnderlineInputBorder(),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value == null) return;
|
|
||||||
settingsService.setBatteryChemistryForRepeater(
|
|
||||||
repeater.publicKeyHex,
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
items: [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 'nmc',
|
|
||||||
child: Text(l10n.appSettings_batteryNmc),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 'lifepo4',
|
|
||||||
child: Text(l10n.appSettings_batteryLifepo4),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 'lipo',
|
|
||||||
child: Text(l10n.appSettings_batteryLipo),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
settingsService.setBatteryChemistryForRepeater(
|
||||||
|
repeater.publicKeyHex,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'nmc',
|
||||||
|
child: Text(l10n.appSettings_batteryNmc),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'lifepo4',
|
||||||
|
child: Text(l10n.appSettings_batteryLifepo4),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'lipo',
|
||||||
|
child: Text(l10n.appSettings_batteryLipo),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
],
|
||||||
Text(
|
|
||||||
isAdmin
|
// ── Tools ──────────────────────────────────────────────────────
|
||||||
? l10n.repeater_managementTools
|
SectionHeader(
|
||||||
: l10n.repeater_guestTools,
|
isAdmin ? l10n.repeater_managementTools : l10n.repeater_guestTools,
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
// Status button
|
_HubActionTile(
|
||||||
_buildManagementCard(
|
index: 0,
|
||||||
context,
|
|
||||||
icon: Icons.analytics,
|
icon: Icons.analytics,
|
||||||
title: l10n.repeater_status,
|
title: l10n.repeater_status,
|
||||||
subtitle: l10n.repeater_statusSubtitle,
|
subtitle: l10n.repeater_statusSubtitle,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
accentColor: MeshPalette.blue,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -220,15 +191,15 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
|
||||||
// Telemetry button
|
_HubActionTile(
|
||||||
_buildManagementCard(
|
index: 1,
|
||||||
context,
|
|
||||||
icon: Icons.bar_chart_sharp,
|
icon: Icons.bar_chart_sharp,
|
||||||
title: l10n.repeater_telemetry,
|
title: l10n.repeater_telemetry,
|
||||||
subtitle: l10n.repeater_telemetrySubtitle,
|
subtitle: l10n.repeater_telemetrySubtitle,
|
||||||
color: Theme.of(context).colorScheme.secondary,
|
accentColor: MeshPalette.magenta,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -237,16 +208,34 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (isAdmin) const SizedBox(height: 12),
|
|
||||||
// CLI button
|
_HubActionTile(
|
||||||
if (isAdmin)
|
index: 2,
|
||||||
_buildManagementCard(
|
icon: Icons.group,
|
||||||
context,
|
title: l10n.repeater_neighbors,
|
||||||
|
subtitle: l10n.repeater_neighborsSubtitle,
|
||||||
|
accentColor: MeshPalette.signal,
|
||||||
|
onTap: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
NeighborsScreen(repeater: repeater, password: password),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
if (isAdmin) ...[
|
||||||
|
_HubActionTile(
|
||||||
|
index: 3,
|
||||||
icon: Icons.terminal,
|
icon: Icons.terminal,
|
||||||
title: l10n.repeater_cli,
|
title: l10n.repeater_cli,
|
||||||
subtitle: l10n.repeater_cliSubtitle,
|
subtitle: l10n.repeater_cliSubtitle,
|
||||||
color: Theme.of(context).colorScheme.tertiary,
|
accentColor: MeshPalette.warn,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -258,34 +247,14 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
_HubActionTile(
|
||||||
// Neighbors button
|
index: 4,
|
||||||
_buildManagementCard(
|
|
||||||
context,
|
|
||||||
icon: Icons.group,
|
|
||||||
title: l10n.repeater_neighbors,
|
|
||||||
subtitle: l10n.repeater_neighborsSubtitle,
|
|
||||||
color: Theme.of(context).colorScheme.tertiary,
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
NeighborsScreen(repeater: repeater, password: password),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (isAdmin) const SizedBox(height: 12),
|
|
||||||
// Settings button
|
|
||||||
if (isAdmin)
|
|
||||||
_buildManagementCard(
|
|
||||||
context,
|
|
||||||
icon: Icons.settings,
|
icon: Icons.settings,
|
||||||
title: l10n.repeater_settings,
|
title: l10n.repeater_settings,
|
||||||
subtitle: l10n.repeater_settingsSubtitle,
|
subtitle: l10n.repeater_settingsSubtitle,
|
||||||
color: Theme.of(context).colorScheme.error,
|
accentColor: MeshPalette.alert,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -297,66 +266,82 @@ class RepeaterHubScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildManagementCard(
|
class _HubActionTile extends StatelessWidget {
|
||||||
BuildContext context, {
|
final int index;
|
||||||
required IconData icon,
|
final IconData icon;
|
||||||
required String title,
|
final String title;
|
||||||
required String subtitle,
|
final String subtitle;
|
||||||
required Color color,
|
final Color accentColor;
|
||||||
required VoidCallback onTap,
|
final VoidCallback onTap;
|
||||||
}) {
|
|
||||||
return Card(
|
const _HubActionTile({
|
||||||
elevation: 2,
|
required this.index,
|
||||||
child: InkWell(
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.accentColor,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return ListEntrance(
|
||||||
|
index: index,
|
||||||
|
child: MeshCard(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: Row(
|
||||||
child: Padding(
|
children: [
|
||||||
padding: const EdgeInsets.all(16),
|
Container(
|
||||||
child: Row(
|
width: 44,
|
||||||
children: [
|
height: 44,
|
||||||
Container(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.all(12),
|
color: accentColor.withValues(alpha: 0.12),
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
||||||
color: color.withValues(alpha: 0.1),
|
border: Border.all(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: accentColor.withValues(alpha: 0.3),
|
||||||
),
|
|
||||||
child: Icon(icon, color: color, size: 32),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(
|
alignment: Alignment.center,
|
||||||
Icons.chevron_right,
|
child: Icon(icon, size: 22, color: accentColor),
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -10,7 +11,9 @@ import '../connector/meshcore_connector.dart';
|
|||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../services/app_settings_service.dart';
|
import '../services/app_settings_service.dart';
|
||||||
import '../services/repeater_command_service.dart';
|
import '../services/repeater_command_service.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
import '../utils/battery_utils.dart';
|
import '../utils/battery_utils.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import '../widgets/routing_sheet.dart';
|
import '../widgets/routing_sheet.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
|
|
||||||
@@ -64,8 +67,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
_commandService = RepeaterCommandService(connector);
|
_commandService = RepeaterCommandService(connector);
|
||||||
_setupMessageListener();
|
_setupMessageListener();
|
||||||
// Defer until after the first frame so any notifyListeners() triggered
|
|
||||||
// during preparePathForContactSend doesn't fire mid-build.
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _loadStatus();
|
if (mounted) _loadStatus();
|
||||||
});
|
});
|
||||||
@@ -81,12 +82,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
|
|
||||||
void _setupMessageListener() {
|
void _setupMessageListener() {
|
||||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||||
|
|
||||||
// Listen for incoming text messages from the repeater
|
|
||||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||||
if (frame.isEmpty) return;
|
if (frame.isEmpty) return;
|
||||||
|
|
||||||
// Check if it's a text message response
|
|
||||||
if (frame[0] == pushCodeStatusResponse) {
|
if (frame[0] == pushCodeStatusResponse) {
|
||||||
_handleStatusResponse(frame);
|
_handleStatusResponse(frame);
|
||||||
} else if (frame[0] == respCodeContactMsgRecv ||
|
} else if (frame[0] == respCodeContactMsgRecv ||
|
||||||
@@ -118,11 +115,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
final parsed = parseContactMessageText(frame);
|
final parsed = parseContactMessageText(frame);
|
||||||
if (parsed == null) return;
|
if (parsed == null) return;
|
||||||
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
|
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
|
||||||
|
|
||||||
// Notify command service of response (for retry handling)
|
|
||||||
_commandService?.handleResponse(widget.repeater, parsed.text);
|
_commandService?.handleResponse(widget.repeater, parsed.text);
|
||||||
|
|
||||||
// Parse status responses
|
|
||||||
_parseStatusResponse(parsed.text);
|
_parseStatusResponse(parsed.text);
|
||||||
_recordStatusResult(true);
|
_recordStatusResult(true);
|
||||||
}
|
}
|
||||||
@@ -131,7 +124,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
if (frame.length < 8) return;
|
if (frame.length < 8) return;
|
||||||
final prefix = frame.sublist(2, 8);
|
final prefix = frame.sublist(2, 8);
|
||||||
if (!_matchesRepeaterPrefix(prefix)) return;
|
if (!_matchesRepeaterPrefix(prefix)) return;
|
||||||
|
|
||||||
if (frame.length < _statusResponseBytes) return;
|
if (frame.length < _statusResponseBytes) return;
|
||||||
|
|
||||||
final data = ByteData.sublistView(
|
final data = ByteData.sublistView(
|
||||||
@@ -254,14 +246,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
_dupFlood = _asInt(data['dup_flood']);
|
_dupFlood = _asInt(data['dup_flood']);
|
||||||
_dupDirect = _asInt(data['dup_direct']);
|
_dupDirect = _asInt(data['dup_direct']);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// Ignore parse failures for non-JSON responses.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {});
|
|
||||||
}
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadStatus() async {
|
Future<void> _loadStatus() async {
|
||||||
@@ -302,9 +289,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
var messageBytes = frame.length >= _statusResponseBytes
|
var messageBytes = frame.length >= _statusResponseBytes
|
||||||
? frame.length
|
? frame.length
|
||||||
: _statusResponseBytes;
|
: _statusResponseBytes;
|
||||||
if (messageBytes < maxFrameSize) {
|
if (messageBytes < maxFrameSize) messageBytes = maxFrameSize;
|
||||||
messageBytes = maxFrameSize;
|
|
||||||
}
|
|
||||||
final timeoutMs = connector.calculateTimeout(
|
final timeoutMs = connector.calculateTimeout(
|
||||||
pathLength: pathLengthValue,
|
pathLength: pathLengthValue,
|
||||||
messageBytes: messageBytes,
|
messageBytes: messageBytes,
|
||||||
@@ -312,9 +297,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
_statusTimeout?.cancel();
|
_statusTimeout?.cancel();
|
||||||
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() => _isLoading = false);
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
showDismissibleSnackBar(
|
showDismissibleSnackBar(
|
||||||
context,
|
context,
|
||||||
content: Text(context.l10n.repeater_statusRequestTimeout),
|
content: Text(context.l10n.repeater_statusRequestTimeout),
|
||||||
@@ -324,10 +307,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() => _isLoading = false);
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
showDismissibleSnackBar(
|
showDismissibleSnackBar(
|
||||||
context,
|
context,
|
||||||
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
|
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
|
||||||
@@ -347,214 +327,6 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
_pendingStatusSelection = null;
|
_pendingStatusSelection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
final connector = context.watch<MeshCoreConnector>();
|
|
||||||
final repeater = _resolveRepeater(connector);
|
|
||||||
final isFloodMode = repeater.pathOverride == -1;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
l10n.repeater_statusTitle,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
repeater.name,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
centerTitle: false,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
|
||||||
tooltip: l10n.repeater_routingMode,
|
|
||||||
onPressed: () =>
|
|
||||||
ContactRoutingSheet.show(context, contact: repeater),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: _isLoading
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.refresh),
|
|
||||||
onPressed: _isLoading ? null : _loadStatus,
|
|
||||||
tooltip: l10n.repeater_refresh,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
|
||||||
top: false,
|
|
||||||
child: RefreshIndicator(
|
|
||||||
onRefresh: _loadStatus,
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
|
||||||
_buildSystemInfoCard(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildRadioStatsCard(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildPacketStatsCard(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSystemInfoCard() {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.info_outline,
|
|
||||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
l10n.repeater_systemInformation,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
_buildInfoRow(l10n.repeater_battery, _batteryText()),
|
|
||||||
_buildInfoRow(l10n.repeater_clockAtLogin, _clockText()),
|
|
||||||
_buildInfoRow(l10n.repeater_uptime, _formatDuration(_uptimeSecs)),
|
|
||||||
_buildInfoRow(l10n.repeater_queueLength, _formatValue(_queueLen)),
|
|
||||||
_buildInfoRow(l10n.repeater_debugFlags, _formatValue(_debugFlags)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRadioStatsCard() {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.radio,
|
|
||||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
l10n.repeater_radioStatistics,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
_buildInfoRow(
|
|
||||||
l10n.repeater_lastRssi,
|
|
||||||
_formatValue(_lastRssi, suffix: ' dB'),
|
|
||||||
),
|
|
||||||
_buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
|
|
||||||
_buildInfoRow(
|
|
||||||
l10n.repeater_noiseFloor,
|
|
||||||
_formatValue(_noiseFloor, suffix: ' dB'),
|
|
||||||
),
|
|
||||||
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
|
|
||||||
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPacketStatsCard() {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.analytics,
|
|
||||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
l10n.repeater_packetStatistics,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
_buildInfoRow(l10n.repeater_sent, _packetTxText()),
|
|
||||||
_buildInfoRow(l10n.repeater_received, _packetRxText()),
|
|
||||||
_buildInfoRow(l10n.repeater_duplicates, _duplicateText()),
|
|
||||||
_buildInfoRow(l10n.repeater_chanUtil, _chanUtilText()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInfoRow(String label, String value) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
|
||||||
textAlign: TextAlign.end,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
int? _asInt(dynamic value) {
|
int? _asInt(dynamic value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (value is int) return value;
|
if (value is int) return value;
|
||||||
@@ -661,4 +433,218 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
|
|||||||
if (snr == null) return '—';
|
if (snr == null) return '—';
|
||||||
return snr.toStringAsFixed(2);
|
return snr.toStringAsFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
|
final repeater = _resolveRepeater(connector);
|
||||||
|
final isFloodMode = repeater.pathOverride == -1;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(l10n.repeater_statusTitle),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||||
|
tooltip: l10n.repeater_routingMode,
|
||||||
|
onPressed: () =>
|
||||||
|
ContactRoutingSheet.show(context, contact: repeater),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh),
|
||||||
|
onPressed: _isLoading ? null : _loadStatus,
|
||||||
|
tooltip: l10n.repeater_refresh,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _loadStatus,
|
||||||
|
child: _isLoading && _batteryMv == null
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _buildBody(l10n, repeater.name),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(dynamic l10n, String name) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
children: [
|
||||||
|
// ── System ─────────────────────────────────────────────────────────
|
||||||
|
SectionHeader(l10n.repeater_systemInformation),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: _buildStatGrid([
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.battery_std,
|
||||||
|
label: l10n.repeater_battery,
|
||||||
|
value: _batteryText(),
|
||||||
|
color: _batteryColor(),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.timer_outlined,
|
||||||
|
label: l10n.repeater_uptime,
|
||||||
|
value: _formatDuration(_uptimeSecs),
|
||||||
|
color: MeshPalette.blue,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.schedule,
|
||||||
|
label: l10n.repeater_clockAtLogin,
|
||||||
|
value: _clockText(),
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.inbox,
|
||||||
|
label: l10n.repeater_queueLength,
|
||||||
|
value: _formatValue(_queueLen),
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.bug_report_outlined,
|
||||||
|
label: l10n.repeater_debugFlags,
|
||||||
|
value: _formatValue(_debugFlags),
|
||||||
|
color: _debugFlags != null && _debugFlags! > 0
|
||||||
|
? MeshPalette.warn
|
||||||
|
: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Radio ──────────────────────────────────────────────────────────
|
||||||
|
SectionHeader(l10n.repeater_radioStatistics),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: _buildStatGrid([
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.signal_cellular_alt,
|
||||||
|
label: l10n.repeater_lastRssi,
|
||||||
|
value: _formatValue(_lastRssi, suffix: ' dB'),
|
||||||
|
color: MeshPalette.blue,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.waves,
|
||||||
|
label: l10n.repeater_lastSnr,
|
||||||
|
value: _formatSnr(_lastSnr),
|
||||||
|
color: MeshTheme.snrColor(_lastSnr, blocked: false),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.noise_control_off,
|
||||||
|
label: l10n.repeater_noiseFloor,
|
||||||
|
value: _formatValue(_noiseFloor, suffix: ' dB'),
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.upload,
|
||||||
|
label: l10n.repeater_txAirtime,
|
||||||
|
value: _formatDuration(_txAirSecs),
|
||||||
|
color: MeshPalette.warn,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.download,
|
||||||
|
label: l10n.repeater_rxAirtime,
|
||||||
|
value: _formatDuration(_rxAirSecs),
|
||||||
|
color: MeshPalette.signal,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Packets ────────────────────────────────────────────────────────
|
||||||
|
SectionHeader(l10n.repeater_packetStatistics),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: _buildStatGrid([
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.send,
|
||||||
|
label: l10n.repeater_sent,
|
||||||
|
value: _packetTxText(),
|
||||||
|
color: MeshPalette.blue,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.call_received,
|
||||||
|
label: l10n.repeater_received,
|
||||||
|
value: _packetRxText(),
|
||||||
|
color: MeshPalette.signal,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.content_copy,
|
||||||
|
label: l10n.repeater_duplicates,
|
||||||
|
value: _duplicateText(),
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
icon: Icons.percent,
|
||||||
|
label: l10n.repeater_chanUtil,
|
||||||
|
value: _chanUtilText(),
|
||||||
|
color: _chanUtil != null && _chanUtil! > 80
|
||||||
|
? MeshPalette.alert
|
||||||
|
: _chanUtil != null && _chanUtil! > 50
|
||||||
|
? MeshPalette.warn
|
||||||
|
: MeshPalette.signal,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _batteryColor() {
|
||||||
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
|
final batteryMv =
|
||||||
|
connector.getRepeaterBatteryMillivolts(widget.repeater.publicKeyHex) ??
|
||||||
|
_batteryMv;
|
||||||
|
if (batteryMv == null) return Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
|
final percent = estimateBatteryPercentFromMillivolts(
|
||||||
|
batteryMv,
|
||||||
|
_batteryChemistry(),
|
||||||
|
);
|
||||||
|
if (percent < 20) return MeshPalette.alert;
|
||||||
|
if (percent < 40) return MeshPalette.warn;
|
||||||
|
return MeshPalette.signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatGrid(List<_StatItem> items) {
|
||||||
|
return GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
childAspectRatio: 2.2,
|
||||||
|
children: items
|
||||||
|
.map((item) => StatTile(
|
||||||
|
icon: item.icon,
|
||||||
|
label: item.label,
|
||||||
|
value: item.value,
|
||||||
|
color: item.color,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatItem {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _StatItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-82
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import '../utils/platform_info.dart';
|
import '../utils/platform_info.dart';
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -7,10 +8,12 @@ import 'package:provider/provider.dart';
|
|||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../services/linux_ble_error_classifier.dart';
|
import '../services/linux_ble_error_classifier.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../widgets/adaptive_app_bar_title.dart';
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
import '../widgets/device_tile.dart';
|
import '../widgets/device_tile.dart';
|
||||||
import '../widgets/empty_state.dart';
|
import '../widgets/empty_state.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
import 'channels_screen.dart';
|
import 'channels_screen.dart';
|
||||||
import 'tcp_screen.dart';
|
import 'tcp_screen.dart';
|
||||||
@@ -136,12 +139,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
builder: (context, connector, child) {
|
builder: (context, connector, child) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Bluetooth off warning
|
// Bluetooth off warning — slides in/out with AnimatedSize
|
||||||
if (_bluetoothState == BluetoothAdapterState.off)
|
AnimatedSize(
|
||||||
_bluetoothOffWarning(context),
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
child: _bluetoothState == BluetoothAdapterState.off
|
||||||
|
? _BluetoothOffBanner(
|
||||||
|
onEnable: PlatformInfo.isAndroid
|
||||||
|
? () => FlutterBluePlus.turnOn()
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
|
||||||
// Status bar
|
// Connection status header
|
||||||
_buildStatusBar(context, connector),
|
_ConnectionStatusHeader(connector: connector),
|
||||||
|
|
||||||
// Device list
|
// Device list
|
||||||
Expanded(child: _buildDeviceList(context, connector)),
|
Expanded(child: _buildDeviceList(context, connector)),
|
||||||
@@ -158,14 +170,31 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
|
|
||||||
return FloatingActionButton.extended(
|
return FloatingActionButton.extended(
|
||||||
heroTag: 'scanner_ble_action',
|
heroTag: 'scanner_ble_action',
|
||||||
onPressed: isBluetoothOff ? null : () => _toggleScan(connector),
|
onPressed: isBluetoothOff
|
||||||
icon: isScanning
|
? null
|
||||||
? const SizedBox(
|
: () {
|
||||||
width: 20,
|
HapticFeedback.lightImpact();
|
||||||
height: 20,
|
_toggleScan(connector);
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
},
|
||||||
)
|
icon: AnimatedSwitcher(
|
||||||
: const Icon(Icons.bluetooth_searching),
|
duration: const Duration(milliseconds: 220),
|
||||||
|
transitionBuilder: (child, anim) =>
|
||||||
|
ScaleTransition(scale: anim, child: child),
|
||||||
|
child: isScanning
|
||||||
|
? SizedBox(
|
||||||
|
key: const ValueKey('scanning'),
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Icons.bluetooth_searching,
|
||||||
|
key: ValueKey('idle'),
|
||||||
|
),
|
||||||
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
isScanning
|
isScanning
|
||||||
? context.l10n.scanner_stop
|
? context.l10n.scanner_stop
|
||||||
@@ -189,51 +218,6 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
|
||||||
String statusText;
|
|
||||||
Color statusColor;
|
|
||||||
|
|
||||||
final l10n = context.l10n;
|
|
||||||
switch (connector.state) {
|
|
||||||
case MeshCoreConnectionState.scanning:
|
|
||||||
statusText = l10n.scanner_scanning;
|
|
||||||
statusColor = Colors.blue;
|
|
||||||
break;
|
|
||||||
case MeshCoreConnectionState.connecting:
|
|
||||||
statusText = l10n.scanner_connecting;
|
|
||||||
statusColor = Colors.orange;
|
|
||||||
break;
|
|
||||||
case MeshCoreConnectionState.connected:
|
|
||||||
statusText = l10n.scanner_connectedTo(connector.deviceDisplayName);
|
|
||||||
statusColor = Colors.green;
|
|
||||||
break;
|
|
||||||
case MeshCoreConnectionState.disconnecting:
|
|
||||||
statusText = l10n.scanner_disconnecting;
|
|
||||||
statusColor = Colors.orange;
|
|
||||||
break;
|
|
||||||
case MeshCoreConnectionState.disconnected:
|
|
||||||
statusText = l10n.scanner_notConnected;
|
|
||||||
statusColor = Colors.grey;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
||||||
color: statusColor.withValues(alpha: 0.1),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.circle, size: 12, color: statusColor),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
statusText,
|
|
||||||
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) {
|
Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) {
|
||||||
if (connector.scanResults.isEmpty) {
|
if (connector.scanResults.isEmpty) {
|
||||||
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
|
final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off;
|
||||||
@@ -251,7 +235,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
action: (isBluetoothOff || isScanning)
|
action: (isBluetoothOff || isScanning)
|
||||||
? null
|
? null
|
||||||
: FilledButton.icon(
|
: FilledButton.icon(
|
||||||
onPressed: () => _toggleScan(connector),
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
_toggleScan(connector);
|
||||||
|
},
|
||||||
icon: const Icon(Icons.bluetooth_searching),
|
icon: const Icon(Icons.bluetooth_searching),
|
||||||
label: Text(context.l10n.scanner_scan),
|
label: Text(context.l10n.scanner_scan),
|
||||||
),
|
),
|
||||||
@@ -259,19 +246,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isConnecting = connector.state == MeshCoreConnectionState.connecting;
|
final isConnecting = connector.state == MeshCoreConnectionState.connecting;
|
||||||
return ListView.separated(
|
return ListView.builder(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 96),
|
||||||
itemCount: connector.scanResults.length,
|
itemCount: connector.scanResults.length,
|
||||||
separatorBuilder: (context, index) => const Divider(),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final result = connector.scanResults[index];
|
final result = connector.scanResults[index];
|
||||||
final deviceId = result.device.remoteId.toString();
|
final deviceId = result.device.remoteId.toString();
|
||||||
return DeviceTile(
|
return ListEntrance(
|
||||||
scanResult: result,
|
index: index,
|
||||||
isConnecting: isConnecting && _connectingDeviceId == deviceId,
|
child: DeviceTile(
|
||||||
onTap: isConnecting
|
scanResult: result,
|
||||||
? null
|
isConnecting: isConnecting && _connectingDeviceId == deviceId,
|
||||||
: () => _connectToDevice(context, connector, result),
|
onTap: isConnecting
|
||||||
|
? null
|
||||||
|
: () => _connectToDevice(context, connector, result),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -413,47 +402,117 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||||||
);
|
);
|
||||||
return pin;
|
return pin;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _bluetoothOffWarning(BuildContext context) {
|
// ── Private sub-widgets ────────────────────────────────────────────────────
|
||||||
final errorColor = Theme.of(context).colorScheme.error;
|
|
||||||
return Container(
|
/// Bluetooth-off warning banner — styled as an alert MeshCard.
|
||||||
width: double.infinity,
|
class _BluetoothOffBanner extends StatelessWidget {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
final VoidCallback? onEnable;
|
||||||
color: errorColor.withValues(alpha: 0.15),
|
|
||||||
|
const _BluetoothOffBanner({this.onEnable});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return MeshCard(
|
||||||
|
color: scheme.error.withValues(alpha: 0.08),
|
||||||
|
borderColor: scheme.error.withValues(alpha: 0.35),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.bluetooth_disabled, size: 24, color: errorColor),
|
Icon(Icons.bluetooth_disabled, size: 20, color: scheme.error),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
context.l10n.scanner_bluetoothOff,
|
context.l10n.scanner_bluetoothOff,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: errorColor,
|
color: scheme.error,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 14,
|
fontSize: 13.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.scanner_bluetoothOffMessage,
|
context.l10n.scanner_bluetoothOffMessage,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: errorColor.withValues(alpha: 0.85),
|
color: scheme.error.withValues(alpha: 0.8),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (PlatformInfo.isAndroid)
|
if (onEnable != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => FlutterBluePlus.turnOn(),
|
onPressed: onEnable,
|
||||||
child: Text(context.l10n.scanner_enableBluetooth),
|
child: Text(context.l10n.scanner_enableBluetooth),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Connection status header with AnimatedSwitcher between states.
|
||||||
|
class _ConnectionStatusHeader extends StatelessWidget {
|
||||||
|
final MeshCoreConnector connector;
|
||||||
|
|
||||||
|
const _ConnectionStatusHeader({required this.connector});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final (String label, Color color, bool pulse) = switch (connector.state) {
|
||||||
|
MeshCoreConnectionState.scanning => (
|
||||||
|
l10n.scanner_scanning,
|
||||||
|
MeshPalette.blue,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
MeshCoreConnectionState.connecting => (
|
||||||
|
l10n.scanner_connecting,
|
||||||
|
MeshPalette.warn,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
MeshCoreConnectionState.connected => (
|
||||||
|
l10n.scanner_connectedTo(connector.deviceDisplayName),
|
||||||
|
MeshPalette.signal,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
MeshCoreConnectionState.disconnecting => (
|
||||||
|
l10n.scanner_disconnecting,
|
||||||
|
MeshPalette.warn,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
MeshCoreConnectionState.disconnected => (
|
||||||
|
l10n.scanner_notConnected,
|
||||||
|
scheme.onSurfaceVariant,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: Align(
|
||||||
|
key: ValueKey(connector.state),
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: StatusChip(
|
||||||
|
label: label,
|
||||||
|
color: color,
|
||||||
|
pulse: pulse,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+538
-394
File diff suppressed because it is too large
Load Diff
+100
-56
@@ -1,13 +1,16 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../services/app_settings_service.dart';
|
import '../services/app_settings_service.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
import '../utils/platform_info.dart';
|
import '../utils/platform_info.dart';
|
||||||
import '../widgets/adaptive_app_bar_title.dart';
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
import 'channels_screen.dart';
|
import 'channels_screen.dart';
|
||||||
import 'usb_screen.dart';
|
import 'usb_screen.dart';
|
||||||
@@ -95,15 +98,32 @@ class _TcpScreenState extends State<TcpScreen> {
|
|||||||
final isConnecting =
|
final isConnecting =
|
||||||
connector.state == MeshCoreConnectionState.connecting &&
|
connector.state == MeshCoreConnectionState.connecting &&
|
||||||
connector.activeTransport == MeshCoreTransportType.tcp;
|
connector.activeTransport == MeshCoreTransportType.tcp;
|
||||||
// A running BLE scan must not block TCP connect: connectTcp() stops
|
// Connect is only available from a fully disconnected state —
|
||||||
// any active scan before connecting, so the only reason to disable
|
// scanning, connecting, or an active session must settle first.
|
||||||
// the button is a TCP connect already in flight.
|
final isButtonDisabled =
|
||||||
final isButtonDisabled = isConnecting;
|
connector.state != MeshCoreConnectionState.disconnected;
|
||||||
return Column(
|
return ListView(
|
||||||
|
padding: const EdgeInsets.only(bottom: 32),
|
||||||
children: [
|
children: [
|
||||||
_buildStatusBar(context, connector),
|
// Status header
|
||||||
_buildTransportLinks(context),
|
|
||||||
Padding(
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: Align(
|
||||||
|
key: ValueKey(connector.state),
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: _buildStatusChip(context, connector),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Transport switcher
|
||||||
|
_buildTransportLinks(context),
|
||||||
|
|
||||||
|
// Connection form
|
||||||
|
const SectionHeader('TCP / IP'),
|
||||||
|
MeshCard(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -113,7 +133,6 @@ class _TcpScreenState extends State<TcpScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: context.l10n.tcpHostLabel,
|
labelText: context.l10n.tcpHostLabel,
|
||||||
hintText: context.l10n.tcpHostHint,
|
hintText: context.l10n.tcpHostHint,
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
),
|
||||||
enabled: !isConnecting,
|
enabled: !isConnecting,
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
@@ -124,7 +143,6 @@ class _TcpScreenState extends State<TcpScreen> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: context.l10n.tcpPortLabel,
|
labelText: context.l10n.tcpPortLabel,
|
||||||
hintText: context.l10n.tcpPortHint,
|
hintText: context.l10n.tcpPortHint,
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
),
|
||||||
enabled: !isConnecting,
|
enabled: !isConnecting,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
@@ -132,7 +150,12 @@ class _TcpScreenState extends State<TcpScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
key: const Key('tcp_connect_button'),
|
key: const Key('tcp_connect_button'),
|
||||||
onPressed: isButtonDisabled ? null : _connectTcp,
|
onPressed: isButtonDisabled
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
_connectTcp();
|
||||||
|
},
|
||||||
icon: isConnecting
|
icon: isConnecting
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
@@ -151,6 +174,39 @@ class _TcpScreenState extends State<TcpScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Last used endpoint
|
||||||
|
if (connector.activeTcpEndpoint != null &&
|
||||||
|
connector.isTcpTransportConnected) ...[
|
||||||
|
const SectionHeader('CONNECTED TO'),
|
||||||
|
MeshCard(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.lan,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
connector.activeTcpEndpoint!,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -159,6 +215,40 @@ class _TcpScreenState extends State<TcpScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusChip(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
|
||||||
|
if (connector.isTcpTransportConnected) {
|
||||||
|
return StatusChip(
|
||||||
|
label: l10n.scanner_connectedTo(
|
||||||
|
connector.activeTcpEndpoint ?? 'TCP',
|
||||||
|
),
|
||||||
|
color: MeshPalette.signal,
|
||||||
|
);
|
||||||
|
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||||
|
return StatusChip(
|
||||||
|
label: l10n.tcpStatus_connectingTo(
|
||||||
|
'${_hostController.text}:${_portController.text}',
|
||||||
|
),
|
||||||
|
color: MeshPalette.warn,
|
||||||
|
pulse: true,
|
||||||
|
);
|
||||||
|
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.tcp) {
|
||||||
|
return StatusChip(
|
||||||
|
label: l10n.scanner_disconnecting,
|
||||||
|
color: MeshPalette.warn,
|
||||||
|
pulse: true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return StatusChip(
|
||||||
|
label: l10n.tcpStatus_notConnected,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildTransportLinks(BuildContext context) {
|
Widget _buildTransportLinks(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
@@ -186,52 +276,6 @@ class _TcpScreenState extends State<TcpScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
String statusText;
|
|
||||||
Color statusColor;
|
|
||||||
|
|
||||||
if (connector.isTcpTransportConnected) {
|
|
||||||
statusText = l10n.scanner_connectedTo(
|
|
||||||
connector.activeTcpEndpoint ?? 'TCP',
|
|
||||||
);
|
|
||||||
statusColor = Colors.green;
|
|
||||||
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
|
||||||
connector.activeTransport == MeshCoreTransportType.tcp) {
|
|
||||||
statusText = l10n.tcpStatus_connectingTo(
|
|
||||||
'${_hostController.text}:${_portController.text}',
|
|
||||||
);
|
|
||||||
statusColor = Colors.orange;
|
|
||||||
} else if (connector.state == MeshCoreConnectionState.disconnecting &&
|
|
||||||
connector.activeTransport == MeshCoreTransportType.tcp) {
|
|
||||||
statusText = l10n.scanner_disconnecting;
|
|
||||||
statusColor = Colors.orange;
|
|
||||||
} else {
|
|
||||||
statusText = l10n.tcpStatus_notConnected;
|
|
||||||
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
||||||
color: statusColor.withValues(alpha: 0.1),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.circle, size: 12, color: statusColor),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
statusText,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _connectTcp() async {
|
Future<void> _connectTcp() async {
|
||||||
if (_connector.state == MeshCoreConnectionState.connecting ||
|
if (_connector.state == MeshCoreConnectionState.connecting ||
|
||||||
_connector.state == MeshCoreConnectionState.connected ||
|
_connector.state == MeshCoreConnectionState.connected ||
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import '../utils/battery_utils.dart';
|
|||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
import '../widgets/sync_progress_overlay.dart';
|
import '../widgets/sync_progress_overlay.dart';
|
||||||
import '../widgets/telemetry_location_map.dart';
|
import '../widgets/telemetry_location_map.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
|
|
||||||
class TelemetryScreen extends StatefulWidget {
|
class TelemetryScreen extends StatefulWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
@@ -319,6 +321,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
final settings = context.watch<AppSettingsService>().settings;
|
final settings = context.watch<AppSettingsService>().settings;
|
||||||
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
|
final isImperialUnits = settings.unitSystem == UnitSystem.imperial;
|
||||||
@@ -387,7 +390,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
l10n.telemetry_noData,
|
l10n.telemetry_noData,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -415,34 +418,21 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
int channel,
|
int channel,
|
||||||
bool isImperialUnits,
|
bool isImperialUnits,
|
||||||
) {
|
) {
|
||||||
return Card(
|
return Column(
|
||||||
child: Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.all(16),
|
children: [
|
||||||
child: Column(
|
SectionHeader(title, padding: const EdgeInsets.fromLTRB(16, 16, 16, 8)),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
MeshCard(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
Row(
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Icon(
|
children: [
|
||||||
Icons.info_outline,
|
for (final entry in channelData.entries)
|
||||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
_buildTelemetryField(entry, channel, isImperialUnits),
|
||||||
),
|
],
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
for (final entry in channelData.entries)
|
|
||||||
_buildTelemetryField(entry, channel, isImperialUnits),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,30 +591,19 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final counterText = _autoRefreshCounterText();
|
final counterText = _autoRefreshCounterText();
|
||||||
|
|
||||||
return Card(
|
return Column(
|
||||||
child: Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.all(16),
|
children: [
|
||||||
child: Column(
|
SectionHeader(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
l10n.common_autoRefresh,
|
||||||
children: [
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
Row(
|
),
|
||||||
children: [
|
MeshCard(
|
||||||
Icon(
|
padding: const EdgeInsets.all(16),
|
||||||
Icons.autorenew,
|
child: Column(
|
||||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
),
|
children: [
|
||||||
const SizedBox(width: 8),
|
_buildAutoRefreshNumberField(
|
||||||
Text(
|
|
||||||
l10n.common_autoRefresh,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
_buildAutoRefreshNumberField(
|
|
||||||
controller: _autoRefreshIntervalController,
|
controller: _autoRefreshIntervalController,
|
||||||
label: l10n.common_interval,
|
label: l10n.common_interval,
|
||||||
min: _autoRefreshMinIntervalSeconds,
|
min: _autoRefreshMinIntervalSeconds,
|
||||||
@@ -684,6 +663,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,6 +893,7 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoRow(String label, String value) {
|
Widget _buildInfoRow(String label, String value) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -922,7 +903,8 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -930,7 +912,10 @@ class _TelemetryScreenState extends State<TelemetryScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 13,
|
||||||
|
color: scheme.onSurface,
|
||||||
|
),
|
||||||
textAlign: TextAlign.end,
|
textAlign: TextAlign.end,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+119
-103
@@ -6,10 +6,13 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/platform_info.dart';
|
import '../utils/platform_info.dart';
|
||||||
import '../utils/usb_port_labels.dart';
|
import '../utils/usb_port_labels.dart';
|
||||||
import '../widgets/adaptive_app_bar_title.dart';
|
import '../widgets/adaptive_app_bar_title.dart';
|
||||||
|
import '../widgets/empty_state.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
import 'channels_screen.dart';
|
import 'channels_screen.dart';
|
||||||
import 'tcp_screen.dart';
|
import 'tcp_screen.dart';
|
||||||
@@ -97,9 +100,27 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
child: Consumer<MeshCoreConnector>(
|
child: Consumer<MeshCoreConnector>(
|
||||||
builder: (context, connector, child) {
|
builder: (context, connector, child) {
|
||||||
return Column(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildStatusBar(context, connector),
|
// Status header
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: Align(
|
||||||
|
key: ValueKey(
|
||||||
|
'${connector.state}_$_isLoadingPorts',
|
||||||
|
),
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: _buildStatusChip(context, connector),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Transport switcher
|
||||||
_buildTransportLinks(context),
|
_buildTransportLinks(context),
|
||||||
|
|
||||||
|
// Port list
|
||||||
Expanded(child: _buildPortList(context, connector)),
|
Expanded(child: _buildPortList(context, connector)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -132,6 +153,52 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusChip(BuildContext context, MeshCoreConnector connector) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
if (_isLoadingPorts) {
|
||||||
|
return StatusChip(
|
||||||
|
label: l10n.usbStatus_searching,
|
||||||
|
color: scheme.primary,
|
||||||
|
pulse: true,
|
||||||
|
);
|
||||||
|
} else if (connector.isUsbTransportConnected) {
|
||||||
|
switch (connector.state) {
|
||||||
|
case MeshCoreConnectionState.connected:
|
||||||
|
return StatusChip(
|
||||||
|
label: l10n.scanner_connectedTo(
|
||||||
|
connector.activeUsbPortDisplayLabel ?? 'USB',
|
||||||
|
),
|
||||||
|
color: MeshPalette.signal,
|
||||||
|
);
|
||||||
|
case MeshCoreConnectionState.disconnecting:
|
||||||
|
return StatusChip(
|
||||||
|
label: l10n.scanner_disconnecting,
|
||||||
|
color: MeshPalette.warn,
|
||||||
|
pulse: true,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return StatusChip(
|
||||||
|
label: l10n.usbStatus_notConnected,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
||||||
|
connector.activeTransport == MeshCoreTransportType.usb) {
|
||||||
|
return StatusChip(
|
||||||
|
label: l10n.usbStatus_connecting,
|
||||||
|
color: MeshPalette.warn,
|
||||||
|
pulse: true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return StatusChip(
|
||||||
|
label: l10n.usbStatus_notConnected,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildTransportLinks(BuildContext context) {
|
Widget _buildTransportLinks(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
@@ -159,105 +226,20 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
|
|
||||||
final l10n = context.l10n;
|
|
||||||
String statusText;
|
|
||||||
Color statusColor;
|
|
||||||
|
|
||||||
if (_isLoadingPorts) {
|
|
||||||
statusText = l10n.usbStatus_searching;
|
|
||||||
statusColor = Theme.of(context).colorScheme.primary;
|
|
||||||
} else if (connector.isUsbTransportConnected) {
|
|
||||||
switch (connector.state) {
|
|
||||||
case MeshCoreConnectionState.connected:
|
|
||||||
statusText = l10n.scanner_connectedTo(
|
|
||||||
connector.activeUsbPortDisplayLabel ?? 'USB',
|
|
||||||
);
|
|
||||||
statusColor = Colors.green;
|
|
||||||
case MeshCoreConnectionState.disconnecting:
|
|
||||||
statusText = l10n.scanner_disconnecting;
|
|
||||||
statusColor = Colors.orange;
|
|
||||||
default:
|
|
||||||
statusText = l10n.usbStatus_notConnected;
|
|
||||||
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
|
||||||
}
|
|
||||||
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
|
||||||
connector.activeTransport == MeshCoreTransportType.usb) {
|
|
||||||
statusText = l10n.usbStatus_connecting;
|
|
||||||
statusColor = Colors.orange;
|
|
||||||
} else {
|
|
||||||
statusText = l10n.usbStatus_notConnected;
|
|
||||||
statusColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
||||||
color: statusColor.withValues(alpha: 0.1),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.circle, size: 12, color: statusColor),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
statusText,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPortList(BuildContext context, MeshCoreConnector connector) {
|
Widget _buildPortList(BuildContext context, MeshCoreConnector connector) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
|
||||||
if (_isLoadingPorts) {
|
if (_isLoadingPorts) {
|
||||||
return Center(
|
return EmptyState(
|
||||||
child: Column(
|
icon: Icons.usb,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
title: l10n.usbStatus_searching,
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.usb,
|
|
||||||
size: 64,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
l10n.usbStatus_searching,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_ports.isEmpty) {
|
if (_ports.isEmpty) {
|
||||||
return Center(
|
return EmptyState(
|
||||||
child: Column(
|
icon: Icons.usb,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
title: l10n.usbScreenEmptyState,
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.usb,
|
|
||||||
size: 64,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
l10n.usbScreenEmptyState,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,10 +247,9 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
connector.state == MeshCoreConnectionState.connecting &&
|
connector.state == MeshCoreConnectionState.connecting &&
|
||||||
connector.activeTransport == MeshCoreTransportType.usb;
|
connector.activeTransport == MeshCoreTransportType.usb;
|
||||||
|
|
||||||
return ListView.separated(
|
return ListView.builder(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.only(bottom: 32),
|
||||||
itemCount: _ports.length,
|
itemCount: _ports.length,
|
||||||
separatorBuilder: (context, index) => const Divider(),
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final port = _ports[index];
|
final port = _ports[index];
|
||||||
final displayName = friendlyUsbPortName(port);
|
final displayName = friendlyUsbPortName(port);
|
||||||
@@ -276,15 +257,50 @@ class _UsbScreenState extends State<UsbScreen> {
|
|||||||
final showRawName =
|
final showRawName =
|
||||||
rawName != displayName && !rawName.startsWith('web:');
|
rawName != displayName && !rawName.startsWith('web:');
|
||||||
|
|
||||||
return ListTile(
|
return ListEntrance(
|
||||||
leading: const Icon(Icons.usb),
|
index: index,
|
||||||
title: Text(
|
child: MeshCard(
|
||||||
displayName,
|
padding: EdgeInsets.zero,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
child: ListTile(
|
||||||
|
onTap: isConnecting
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
_connectPort(port);
|
||||||
|
},
|
||||||
|
leading: AvatarCircle(
|
||||||
|
name: displayName,
|
||||||
|
size: 40,
|
||||||
|
icon: Icons.usb,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
displayName,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: showRawName
|
||||||
|
? Text(
|
||||||
|
rawName,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
trailing: Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 18,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
subtitle: showRawName ? Text(rawName) : null,
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: isConnecting ? null : () => _connectPort(port),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+210
-48
@@ -1,47 +1,48 @@
|
|||||||
|
import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// MeshCore palette — cool slate dark theme with sky-blue accents.
|
/// MeshCore palette — high-contrast slate surfaces with sky-blue accents.
|
||||||
class MeshPalette {
|
class MeshPalette {
|
||||||
MeshPalette._();
|
MeshPalette._();
|
||||||
|
|
||||||
// Surfaces (cool near-black, slate undertone)
|
// Surfaces shared with the map overlays and navigation.
|
||||||
static const bg = Color(0xFF101417);
|
static const bg = Color(0xFF0B1220);
|
||||||
static const bg1 = Color(0xFF161B1F);
|
static const bg1 = Color(0xFF0F172A);
|
||||||
static const bg2 = Color(0xFF1D242A);
|
static const bg2 = Color(0xFF162033);
|
||||||
static const bg3 = Color(0xFF28313A);
|
static const bg3 = Color(0xFF1E293B);
|
||||||
static const bg4 = Color(0xFF344049);
|
static const bg4 = Color(0xFF334155);
|
||||||
|
|
||||||
// Lines
|
// Lines
|
||||||
static const line = Color(0xFF222B31);
|
static const line = Color(0xFF1E293B);
|
||||||
static const line2 = Color(0xFF344049);
|
static const line2 = Color(0xFF334155);
|
||||||
static const line3 = Color(0xFF485762);
|
static const line3 = Color(0xFF475569);
|
||||||
|
|
||||||
// Ink
|
// Ink
|
||||||
static const ink = Color(0xFFE9EEF3);
|
static const ink = Color(0xFFF8FAFC);
|
||||||
static const ink2 = Color(0xFFB5C0C9);
|
static const ink2 = Color(0xFFCBD5E1);
|
||||||
static const ink3 = Color(0xFF7C8A95);
|
static const ink3 = Color(0xFF94A3B8);
|
||||||
static const ink4 = Color(0xFF556470);
|
static const ink4 = Color(0xFF64748B);
|
||||||
|
|
||||||
// Signal-quality green (used only for SNR coloring, not UI chrome)
|
// Signal-quality green (used only for SNR coloring, not UI chrome)
|
||||||
static const signal = Color(0xFF7BEFA8);
|
static const signal = Color(0xFF22C55E);
|
||||||
static const signalDim = Color(0xFF4DC580);
|
static const signalDim = Color(0xFF16A34A);
|
||||||
|
|
||||||
// Warn (ember)
|
// Warn
|
||||||
static const warn = Color(0xFFFFA552);
|
static const warn = Color(0xFFF59E0B);
|
||||||
static const warnDim = Color(0xFFC27E3C);
|
static const warnDim = Color(0xFFD97706);
|
||||||
static const warnBg = Color(0x1CFFA552);
|
static const warnBg = Color(0x1FF59E0B);
|
||||||
static const warnLine = Color(0x4DFFA552);
|
static const warnLine = Color(0x66F59E0B);
|
||||||
|
|
||||||
// Alert (coral)
|
// Alert
|
||||||
static const alert = Color(0xFFFF6A5C);
|
static const alert = Color(0xFFEF4444);
|
||||||
static const alertBg = Color(0x1CFF6A5C);
|
static const alertBg = Color(0x1FEF4444);
|
||||||
static const alertLine = Color(0x52FF6A5C);
|
static const alertLine = Color(0x66EF4444);
|
||||||
|
|
||||||
// Blue (sky) — primary accent
|
// Blue — primary map/app accent
|
||||||
static const blue = Color(0xFF7FCBF5);
|
static const blue = Color(0xFF0EA5E9);
|
||||||
static const blueDim = Color(0xFF4A9CC9);
|
static const blueDim = Color(0xFF0284C7);
|
||||||
static const blueBg = Color(0x1C7FCBF5);
|
static const blueBg = Color(0x290EA5E9);
|
||||||
static const blueLine = Color(0x477FCBF5);
|
static const blueLine = Color(0x800EA5E9);
|
||||||
|
|
||||||
// Magenta
|
// Magenta
|
||||||
static const magenta = Color(0xFFDE7FDB);
|
static const magenta = Color(0xFFDE7FDB);
|
||||||
@@ -49,9 +50,9 @@ class MeshPalette {
|
|||||||
static const magentaLine = Color(0x47DE7FDB);
|
static const magentaLine = Color(0x47DE7FDB);
|
||||||
|
|
||||||
// Me bubble (dusk blue)
|
// Me bubble (dusk blue)
|
||||||
static const me = Color(0xFF1B2C3D);
|
static const me = Color(0xFF0C4A6E);
|
||||||
static const meBorder = Color(0xFF2C4A66);
|
static const meBorder = Color(0xFF0369A1);
|
||||||
static const meInk = Color(0xFFDCE9F5);
|
static const meInk = Color(0xFFF0F9FF);
|
||||||
|
|
||||||
// ── Light variant (used when user explicitly picks light theme)
|
// ── Light variant (used when user explicitly picks light theme)
|
||||||
static const lightBg = Color(0xFFF4F6F8);
|
static const lightBg = Color(0xFFF4F6F8);
|
||||||
@@ -64,6 +65,51 @@ class MeshPalette {
|
|||||||
static const lightBlue = Color(0xFF2F6EA8);
|
static const lightBlue = Color(0xFF2F6EA8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// High-contrast semantic colors for UI rendered over variable map tiles.
|
||||||
|
class MapPalette {
|
||||||
|
MapPalette._();
|
||||||
|
|
||||||
|
static const online = Color(0xFF22C55E);
|
||||||
|
static const offline = Color(0xFF6B7280);
|
||||||
|
static const stale = Color(0xFFF59E0B);
|
||||||
|
static const repeater = Color(0xFF2563EB);
|
||||||
|
static const router = Color(0xFF7C3AED);
|
||||||
|
static const batteryLow = Color(0xFFEF4444);
|
||||||
|
static const cluster = Color(0xFFF97316);
|
||||||
|
static const selected = Color(0xFF0EA5E9);
|
||||||
|
static const sensor = Color(0xFF0F766E);
|
||||||
|
static const shared = Color(0xFF0369A1);
|
||||||
|
|
||||||
|
static const panelLight = Color(0xF0FFFFFF);
|
||||||
|
static const panelDark = Color(0xF50B1220);
|
||||||
|
static const textPrimary = Color(0xFFF8FAFC);
|
||||||
|
static const textSecondary = Color(0xFFCBD5E1);
|
||||||
|
static const textMuted = Color(0xFF94A3B8);
|
||||||
|
static const border = Color(0x5264758B);
|
||||||
|
static const markerOutline = Colors.white;
|
||||||
|
static const markerShadow = Color(0xB3000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-contrast colors for line-of-sight maps and elevation profiles.
|
||||||
|
class LosPalette {
|
||||||
|
LosPalette._();
|
||||||
|
|
||||||
|
static const terrain = Color(0xFFA3E635);
|
||||||
|
static const beam = Color(0xFF38BDF8);
|
||||||
|
static const horizon = Color(0xFFFBBF24);
|
||||||
|
static const blocked = Color(0xFFEF4444);
|
||||||
|
static const marginal = Color(0xFFF59E0B);
|
||||||
|
static const clear = Color(0xFF22C55E);
|
||||||
|
static const selected = Color(0xFF0EA5E9);
|
||||||
|
static const chartBackground = Color(0xFF0B1220);
|
||||||
|
static const panelDark = Color(0xF00F172A);
|
||||||
|
static const panelLight = Color(0xF5FFFFFF);
|
||||||
|
static const text = Color(0xFFF8FAFC);
|
||||||
|
static const textMuted = Color(0xFFCBD5E1);
|
||||||
|
static const border = Color(0x5264758B);
|
||||||
|
static const shadow = Color(0x99000000);
|
||||||
|
}
|
||||||
|
|
||||||
/// Named font stacks — Flutter falls back to system fonts when the named
|
/// Named font stacks — Flutter falls back to system fonts when the named
|
||||||
/// family isn't installed, keeping things working without bundled assets.
|
/// family isn't installed, keeping things working without bundled assets.
|
||||||
class MeshFonts {
|
class MeshFonts {
|
||||||
@@ -72,6 +118,7 @@ class MeshFonts {
|
|||||||
static const sans = 'Inter';
|
static const sans = 'Inter';
|
||||||
static const mono = 'JetBrains Mono';
|
static const mono = 'JetBrains Mono';
|
||||||
static const display = 'Instrument Serif';
|
static const display = 'Instrument Serif';
|
||||||
|
static const emoji = 'Noto Color Emoji';
|
||||||
|
|
||||||
static const List<String> sansFallback = [
|
static const List<String> sansFallback = [
|
||||||
'system-ui',
|
'system-ui',
|
||||||
@@ -93,6 +140,11 @@ class MeshFonts {
|
|||||||
'Times New Roman',
|
'Times New Roman',
|
||||||
'serif',
|
'serif',
|
||||||
];
|
];
|
||||||
|
static const List<String> emojiFallback = [
|
||||||
|
'Apple Color Emoji',
|
||||||
|
'Segoe UI Emoji',
|
||||||
|
'Noto Emoji',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Radii used consistently across the app.
|
/// Radii used consistently across the app.
|
||||||
@@ -113,21 +165,21 @@ class MeshTheme {
|
|||||||
static ThemeData dark() {
|
static ThemeData dark() {
|
||||||
const scheme = ColorScheme.dark(
|
const scheme = ColorScheme.dark(
|
||||||
primary: MeshPalette.blue,
|
primary: MeshPalette.blue,
|
||||||
onPrimary: Color(0xFF0A1A26),
|
onPrimary: Colors.white,
|
||||||
primaryContainer: MeshPalette.blueBg,
|
primaryContainer: Color(0xFF075985),
|
||||||
onPrimaryContainer: MeshPalette.blue,
|
onPrimaryContainer: Colors.white,
|
||||||
secondary: MeshPalette.magenta,
|
secondary: MeshPalette.magenta,
|
||||||
onSecondary: Color(0xFF201020),
|
onSecondary: Colors.white,
|
||||||
secondaryContainer: Color(0xFF331A33),
|
secondaryContainer: Color(0xFF331A33),
|
||||||
onSecondaryContainer: MeshPalette.magenta,
|
onSecondaryContainer: Colors.white,
|
||||||
tertiary: MeshPalette.warn,
|
tertiary: MeshPalette.warn,
|
||||||
onTertiary: Color(0xFF1F1206),
|
onTertiary: Color(0xFF0B1220),
|
||||||
tertiaryContainer: Color(0xFF3A2710),
|
tertiaryContainer: Color(0xFF78350F),
|
||||||
onTertiaryContainer: Color(0xFFFFC58A),
|
onTertiaryContainer: Colors.white,
|
||||||
error: MeshPalette.alert,
|
error: MeshPalette.alert,
|
||||||
onError: Color(0xFF1A0A08),
|
onError: Colors.white,
|
||||||
errorContainer: MeshPalette.alertBg,
|
errorContainer: Color(0xFF7F1D1D),
|
||||||
onErrorContainer: MeshPalette.alert,
|
onErrorContainer: Colors.white,
|
||||||
surface: MeshPalette.bg,
|
surface: MeshPalette.bg,
|
||||||
onSurface: MeshPalette.ink,
|
onSurface: MeshPalette.ink,
|
||||||
surfaceContainerLowest: MeshPalette.bg,
|
surfaceContainerLowest: MeshPalette.bg,
|
||||||
@@ -334,9 +386,9 @@ class MeshTheme {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
),
|
),
|
||||||
navigationBarTheme: NavigationBarThemeData(
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
backgroundColor: scheme.surfaceContainerLow,
|
backgroundColor: scheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
indicatorColor: scheme.primary.withValues(alpha: 0.14),
|
indicatorColor: scheme.primary,
|
||||||
indicatorShape: RoundedRectangleBorder(
|
indicatorShape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(MeshRadii.md),
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
||||||
),
|
),
|
||||||
@@ -348,13 +400,13 @@ class MeshTheme {
|
|||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
letterSpacing: 0.1,
|
letterSpacing: 0.1,
|
||||||
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||||
final selected = states.contains(WidgetState.selected);
|
final selected = states.contains(WidgetState.selected);
|
||||||
return IconThemeData(
|
return IconThemeData(
|
||||||
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
color: selected ? scheme.onPrimary : scheme.onSurfaceVariant,
|
||||||
size: 22,
|
size: 22,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -393,6 +445,106 @@ class MeshTheme {
|
|||||||
),
|
),
|
||||||
iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 22),
|
iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 22),
|
||||||
splashFactory: InkSparkle.splashFactory,
|
splashFactory: InkSparkle.splashFactory,
|
||||||
|
pageTransitionsTheme: const PageTransitionsTheme(
|
||||||
|
builders: {
|
||||||
|
TargetPlatform.android: FadeForwardsPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.linux: FadeForwardsPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.macOS: FadeForwardsPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.windows: FadeForwardsPageTransitionsBuilder(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
segmentedButtonTheme: SegmentedButtonThemeData(
|
||||||
|
style: SegmentedButton.styleFrom(
|
||||||
|
selectedBackgroundColor: scheme.primary.withValues(alpha: 0.16),
|
||||||
|
selectedForegroundColor: scheme.primary,
|
||||||
|
side: BorderSide(color: scheme.outlineVariant),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontFamily: MeshFonts.sans,
|
||||||
|
fontFamilyFallback: MeshFonts.sansFallback,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
switchTheme: SwitchThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(WidgetState.selected)
|
||||||
|
? scheme.onPrimary
|
||||||
|
: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
trackColor: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(WidgetState.selected)
|
||||||
|
? scheme.primary
|
||||||
|
: scheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
trackOutlineColor: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(WidgetState.selected)
|
||||||
|
? Colors.transparent
|
||||||
|
: scheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
sliderTheme: SliderThemeData(
|
||||||
|
activeTrackColor: scheme.primary,
|
||||||
|
inactiveTrackColor: scheme.surfaceContainerHighest,
|
||||||
|
thumbColor: scheme.primary,
|
||||||
|
overlayColor: scheme.primary.withValues(alpha: 0.12),
|
||||||
|
valueIndicatorColor: scheme.surfaceContainerHighest,
|
||||||
|
valueIndicatorTextStyle: TextStyle(
|
||||||
|
fontFamily: MeshFonts.mono,
|
||||||
|
fontFamilyFallback: MeshFonts.monoFallback,
|
||||||
|
color: scheme.onSurface,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
trackHeight: 3,
|
||||||
|
),
|
||||||
|
tabBarTheme: TabBarThemeData(
|
||||||
|
labelColor: scheme.primary,
|
||||||
|
unselectedLabelColor: scheme.onSurfaceVariant,
|
||||||
|
indicatorColor: scheme.primary,
|
||||||
|
dividerColor: scheme.outlineVariant,
|
||||||
|
labelStyle: const TextStyle(
|
||||||
|
fontFamily: MeshFonts.sans,
|
||||||
|
fontFamilyFallback: MeshFonts.sansFallback,
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
unselectedLabelStyle: const TextStyle(
|
||||||
|
fontFamily: MeshFonts.sans,
|
||||||
|
fontFamilyFallback: MeshFonts.sansFallback,
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||||
|
color: scheme.primary,
|
||||||
|
linearTrackColor: scheme.surfaceContainerHigh,
|
||||||
|
circularTrackColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
tooltipTheme: TooltipThemeData(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(MeshRadii.sm),
|
||||||
|
border: Border.all(color: scheme.outline),
|
||||||
|
),
|
||||||
|
textStyle: TextStyle(color: scheme.onSurface, fontSize: 12),
|
||||||
|
),
|
||||||
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: scheme.primary,
|
||||||
|
foregroundColor: scheme.onPrimary,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontFamily: MeshFonts.sans,
|
||||||
|
fontFamilyFallback: MeshFonts.sansFallback,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +595,16 @@ class MeshTheme {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Color-emoji style with platform fallbacks and stable vertical metrics.
|
||||||
|
static TextStyle emoji({double fontSize = 28}) {
|
||||||
|
return TextStyle(
|
||||||
|
fontFamily: MeshFonts.emoji,
|
||||||
|
fontFamilyFallback: MeshFonts.emojiFallback,
|
||||||
|
fontSize: fontSize,
|
||||||
|
height: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Color-code an SNR value for consistency across the app.
|
/// Color-code an SNR value for consistency across the app.
|
||||||
static Color snrColor(num? snr, {required bool blocked}) {
|
static Color snrColor(num? snr, {required bool blocked}) {
|
||||||
if (blocked) return MeshPalette.alert;
|
if (blocked) return MeshPalette.alert;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
|
||||||
class BatteryUi {
|
class BatteryUi {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
@@ -10,19 +11,19 @@ class BatteryUi {
|
|||||||
|
|
||||||
BatteryUi batteryUiForPercent(int? percent) {
|
BatteryUi batteryUiForPercent(int? percent) {
|
||||||
if (percent == null) {
|
if (percent == null) {
|
||||||
return const BatteryUi(Icons.battery_unknown, Colors.grey);
|
return const BatteryUi(Icons.battery_unknown, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
final p = percent.clamp(0, 100);
|
final p = percent.clamp(0, 100);
|
||||||
|
|
||||||
return switch (p) {
|
return switch (p) {
|
||||||
<= 5 => const BatteryUi(Icons.battery_alert, Colors.redAccent),
|
<= 5 => const BatteryUi(Icons.battery_alert, MeshPalette.alert),
|
||||||
<= 15 => const BatteryUi(Icons.battery_0_bar, Colors.redAccent),
|
<= 15 => const BatteryUi(Icons.battery_0_bar, MeshPalette.alert),
|
||||||
<= 30 => const BatteryUi(Icons.battery_1_bar, Colors.orange),
|
<= 30 => const BatteryUi(Icons.battery_1_bar, MeshPalette.warn),
|
||||||
<= 45 => const BatteryUi(Icons.battery_2_bar, Colors.amber),
|
<= 45 => const BatteryUi(Icons.battery_2_bar, MeshPalette.warn),
|
||||||
<= 60 => const BatteryUi(Icons.battery_3_bar, Colors.lightGreen),
|
<= 60 => const BatteryUi(Icons.battery_3_bar, null),
|
||||||
<= 80 => const BatteryUi(Icons.battery_5_bar, Colors.green),
|
<= 80 => const BatteryUi(Icons.battery_5_bar, null),
|
||||||
_ => const BatteryUi(Icons.battery_full, Colors.green),
|
_ => const BatteryUi(Icons.battery_full, MeshPalette.signal),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +77,9 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
|
|||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
displayText,
|
displayText,
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w600,
|
||||||
color: batteryUi.color,
|
color: batteryUi.color,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
|
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
import 'mesh_ui.dart';
|
||||||
import 'signal_ui.dart';
|
import 'signal_ui.dart';
|
||||||
|
|
||||||
/// A reusable tile widget for displaying a MeshCore device in a list
|
/// A MeshCard-based row for displaying a scanned BLE device.
|
||||||
|
/// Shows an AvatarCircle (router icon, deterministic hue from device name),
|
||||||
|
/// device name, mono MAC address, mono RSSI dBm, and SignalBars on the right.
|
||||||
|
/// While connecting, shows a small progress ring instead of signal bars.
|
||||||
class DeviceTile extends StatelessWidget {
|
class DeviceTile extends StatelessWidget {
|
||||||
final ScanResult scanResult;
|
final ScanResult scanResult;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
@@ -23,27 +30,10 @@ class DeviceTile extends StatelessWidget {
|
|||||||
final name = device.platformName.isNotEmpty
|
final name = device.platformName.isNotEmpty
|
||||||
? device.platformName
|
? device.platformName
|
||||||
: scanResult.advertisementData.advName;
|
: scanResult.advertisementData.advName;
|
||||||
|
final displayName = name.isNotEmpty ? name : context.l10n.common_unknownDevice;
|
||||||
|
final mac = device.remoteId.toString();
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
enabled: onTap != null || isConnecting,
|
|
||||||
leading: _buildSignalIcon(rssi),
|
|
||||||
title: Text(
|
|
||||||
name.isNotEmpty ? name : context.l10n.common_unknownDevice,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
subtitle: Text(device.remoteId.toString()),
|
|
||||||
trailing: isConnecting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: onTap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSignalIcon(int rssi) {
|
|
||||||
final tier = rssi >= -60
|
final tier = rssi >= -60
|
||||||
? 0
|
? 0
|
||||||
: rssi >= -70
|
: rssi >= -70
|
||||||
@@ -55,15 +45,77 @@ class DeviceTile extends StatelessWidget {
|
|||||||
: 4;
|
: 4;
|
||||||
final signalUi = signalUiForStrengthTier(tier);
|
final signalUi = signalUiForStrengthTier(tier);
|
||||||
|
|
||||||
return Column(
|
return MeshCard(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
onTap: onTap == null
|
||||||
children: [
|
? null
|
||||||
Icon(signalUi.icon, color: signalUi.color),
|
: () {
|
||||||
Text(
|
HapticFeedback.selectionClick();
|
||||||
'$rssi dBm',
|
onTap!();
|
||||||
style: TextStyle(fontSize: 10, color: signalUi.color),
|
},
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
],
|
child: Row(
|
||||||
|
children: [
|
||||||
|
AvatarCircle(
|
||||||
|
name: displayName,
|
||||||
|
size: 42,
|
||||||
|
icon: Icons.router,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
displayName,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: scheme.onSurface,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
mac,
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
if (isConnecting)
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: scheme.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Icon(signalUi.icon, size: 16, color: signalUi.color),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'$rssi dBm',
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 10,
|
||||||
|
color: signalUi.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,31 +29,68 @@ class FeatureToggleRow extends StatefulWidget {
|
|||||||
class _FeatureToggleRow extends State<FeatureToggleRow> {
|
class _FeatureToggleRow extends State<FeatureToggleRow> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
final scheme = Theme.of(context).colorScheme;
|
||||||
children: [
|
final textTheme = Theme.of(context).textTheme;
|
||||||
Expanded(
|
|
||||||
child: SwitchListTile(
|
return Padding(
|
||||||
title: Text(widget.title),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
subtitle: Text(widget.subtitle),
|
child: Row(
|
||||||
value: widget.value,
|
children: [
|
||||||
onChanged: widget.onChanged,
|
Expanded(
|
||||||
contentPadding: EdgeInsets.zero,
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.subtitle,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
if (widget.hasRefreshing)
|
Row(
|
||||||
IconButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
icon: widget.isRefreshing
|
children: [
|
||||||
? const SizedBox(
|
Switch(
|
||||||
width: 18,
|
value: widget.value,
|
||||||
height: 18,
|
onChanged: widget.onChanged,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
),
|
||||||
)
|
if (widget.hasRefreshing) ...[
|
||||||
: const Icon(Icons.refresh, size: 20),
|
const SizedBox(width: 4),
|
||||||
onPressed: widget.isRefreshing ? null : widget.onRefresh,
|
widget.isRefreshing
|
||||||
tooltip: widget.refreshTooltip,
|
? SizedBox(
|
||||||
visualDensity: VisualDensity.compact,
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
color: scheme.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
|
onPressed: widget.onRefresh,
|
||||||
|
tooltip: widget.refreshTooltip,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 32,
|
||||||
|
minHeight: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
|
||||||
class EmojiPicker extends StatelessWidget {
|
class EmojiPicker extends StatelessWidget {
|
||||||
final Function(String) onEmojiSelected;
|
final Function(String) onEmojiSelected;
|
||||||
@@ -257,7 +258,11 @@ class EmojiPicker extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
emoji,
|
emoji,
|
||||||
style: const TextStyle(fontSize: 28),
|
style: MeshTheme.emoji(),
|
||||||
|
textHeightBehavior: const TextHeightBehavior(
|
||||||
|
applyHeightToFirstAscent: false,
|
||||||
|
applyHeightToLastDescent: false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -298,7 +303,12 @@ class EmojiPicker extends StatelessWidget {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
emojis[index],
|
emojis[index],
|
||||||
style: const TextStyle(fontSize: 28),
|
style: MeshTheme.emoji(),
|
||||||
|
textHeightBehavior:
|
||||||
|
const TextHeightBehavior(
|
||||||
|
applyHeightToFirstAscent: false,
|
||||||
|
applyHeightToLastDescent: false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// A centered empty state display with icon, title, and optional subtitle/action.
|
/// A centered empty state display with icon, title, and optional subtitle/action.
|
||||||
class EmptyState extends StatelessWidget {
|
/// Features a tinted icon circle, fade+slide entrance animation, and clear
|
||||||
|
/// typography hierarchy using the MeshCore design system.
|
||||||
|
class EmptyState extends StatefulWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
@@ -15,29 +17,97 @@ class EmptyState extends StatelessWidget {
|
|||||||
this.action,
|
this.action,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EmptyState> createState() => _EmptyStateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyStateState extends State<EmptyState>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 420),
|
||||||
|
);
|
||||||
|
late final CurvedAnimation _curve = CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_curve.dispose();
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Center(
|
return FadeTransition(
|
||||||
child: Column(
|
opacity: _curve,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: SlideTransition(
|
||||||
children: [
|
position: Tween(
|
||||||
Icon(icon, size: 64, color: onSurfaceVariant.withValues(alpha: 0.6)),
|
begin: const Offset(0, 0.06),
|
||||||
const SizedBox(height: 16),
|
end: Offset.zero,
|
||||||
Text(title, style: TextStyle(fontSize: 16, color: onSurfaceVariant)),
|
).animate(_curve),
|
||||||
if (subtitle != null) ...[
|
child: Center(
|
||||||
const SizedBox(height: 8),
|
child: Padding(
|
||||||
Text(
|
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||||
subtitle!,
|
child: Column(
|
||||||
style: TextStyle(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
fontSize: 14,
|
children: [
|
||||||
color: onSurfaceVariant.withValues(alpha: 0.8),
|
Container(
|
||||||
),
|
width: 80,
|
||||||
textAlign: TextAlign.center,
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: scheme.primary.withValues(alpha: 0.08),
|
||||||
|
border: Border.all(
|
||||||
|
color: scheme.primary.withValues(alpha: 0.18),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
widget.icon,
|
||||||
|
size: 36,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: scheme.onSurface,
|
||||||
|
letterSpacing: -0.1,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (widget.subtitle != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
widget.subtitle!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
height: 1.45,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (widget.action != null) ...[
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
widget.action!,
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
if (action != null) ...[const SizedBox(height: 24), action!],
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../helpers/chat_scroll_controller.dart';
|
import '../helpers/chat_scroll_controller.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
|
||||||
class JumpToBottomButton extends StatelessWidget {
|
class JumpToBottomButton extends StatelessWidget {
|
||||||
final ChatScrollController scrollController;
|
final ChatScrollController scrollController;
|
||||||
@@ -8,6 +10,7 @@ class JumpToBottomButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return ValueListenableBuilder<bool>(
|
return ValueListenableBuilder<bool>(
|
||||||
valueListenable: scrollController.showJumpToBottom,
|
valueListenable: scrollController.showJumpToBottom,
|
||||||
builder: (context, show, _) {
|
builder: (context, show, _) {
|
||||||
@@ -15,9 +18,36 @@ class JumpToBottomButton extends StatelessWidget {
|
|||||||
return Positioned(
|
return Positioned(
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 16,
|
bottom: 16,
|
||||||
child: FloatingActionButton.small(
|
child: Material(
|
||||||
onPressed: scrollController.jumpToBottom,
|
color: Colors.transparent,
|
||||||
child: const Icon(Icons.keyboard_arrow_down),
|
child: InkWell(
|
||||||
|
onTap: scrollController.jumpToBottom,
|
||||||
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: scheme.surfaceContainerHigh.withValues(alpha: 0.92),
|
||||||
|
border: Border.all(
|
||||||
|
color: scheme.outlineVariant,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.keyboard_arrow_down,
|
||||||
|
size: 22,
|
||||||
|
color: scheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
|
||||||
class MessageStatusIcon extends StatefulWidget {
|
class MessageStatusIcon extends StatefulWidget {
|
||||||
final bool isAcked;
|
final bool isAcked;
|
||||||
@@ -71,7 +72,11 @@ class _MessageStatusIconState extends State<MessageStatusIcon>
|
|||||||
if (widget.isFailed) {
|
if (widget.isFailed) {
|
||||||
return Semantics(
|
return Semantics(
|
||||||
label: l10n.messageStatus_failed,
|
label: l10n.messageStatus_failed,
|
||||||
child: Icon(Icons.cancel, size: size, color: colorScheme.error),
|
child: Icon(
|
||||||
|
Icons.cancel,
|
||||||
|
size: size,
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +97,10 @@ class _MessageStatusIconState extends State<MessageStatusIcon>
|
|||||||
: widget.isAcked
|
: widget.isAcked
|
||||||
? l10n.messageStatus_delivered
|
? l10n.messageStatus_delivered
|
||||||
: l10n.messageStatus_sent;
|
: l10n.messageStatus_sent;
|
||||||
final Color color = delivered ? colorScheme.tertiary : baseColor;
|
// Use palette colors: tertiary (warn/amber) for acked/repeated, base for sent.
|
||||||
|
final Color color = delivered
|
||||||
|
? MeshPalette.signal.withValues(alpha: 0.9)
|
||||||
|
: baseColor;
|
||||||
|
|
||||||
return Semantics(
|
return Semantics(
|
||||||
label: label,
|
label: label,
|
||||||
|
|||||||
@@ -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 'package:flutter/material.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
|
||||||
class QuickSwitchBar extends StatelessWidget {
|
class QuickSwitchBar extends StatelessWidget {
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onDestinationSelected;
|
final ValueChanged<int> onDestinationSelected;
|
||||||
final int contactsUnreadCount;
|
final int contactsUnreadCount;
|
||||||
final int channelsUnreadCount;
|
final int channelsUnreadCount;
|
||||||
|
final bool highContrast;
|
||||||
|
|
||||||
const QuickSwitchBar({
|
const QuickSwitchBar({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -15,6 +17,7 @@ class QuickSwitchBar extends StatelessWidget {
|
|||||||
required this.onDestinationSelected,
|
required this.onDestinationSelected,
|
||||||
this.contactsUnreadCount = 0,
|
this.contactsUnreadCount = 0,
|
||||||
this.channelsUnreadCount = 0,
|
this.channelsUnreadCount = 0,
|
||||||
|
this.highContrast = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -22,6 +25,14 @@ class QuickSwitchBar extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final labelStyle = theme.textTheme.labelMedium ?? const TextStyle();
|
final labelStyle = theme.textTheme.labelMedium ?? const TextStyle();
|
||||||
|
final background = highContrast ? MapPalette.panelDark : Colors.transparent;
|
||||||
|
final selectedColor = highContrast
|
||||||
|
? MapPalette.textPrimary
|
||||||
|
: colorScheme.onPrimary;
|
||||||
|
final unselectedColor = highContrast
|
||||||
|
? MapPalette.textSecondary
|
||||||
|
: colorScheme.onSurfaceVariant;
|
||||||
|
final indicator = highContrast ? MapPalette.selected : colorScheme.primary;
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -31,9 +42,11 @@ class QuickSwitchBar extends StatelessWidget {
|
|||||||
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
|
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.transparent,
|
color: background,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
color: highContrast
|
||||||
|
? MapPalette.border
|
||||||
|
: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: NavigationBarTheme(
|
child: NavigationBarTheme(
|
||||||
@@ -41,22 +54,18 @@ class QuickSwitchBar extends StatelessWidget {
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
shadowColor: Colors.transparent,
|
shadowColor: Colors.transparent,
|
||||||
indicatorColor: colorScheme.primaryContainer,
|
indicatorColor: indicator,
|
||||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||||
final isSelected = states.contains(WidgetState.selected);
|
final isSelected = states.contains(WidgetState.selected);
|
||||||
return labelStyle.copyWith(
|
return labelStyle.copyWith(
|
||||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||||
color: isSelected
|
color: isSelected ? selectedColor : unselectedColor,
|
||||||
? colorScheme.onPrimaryContainer
|
|
||||||
: colorScheme.onSurfaceVariant,
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||||
final isSelected = states.contains(WidgetState.selected);
|
final isSelected = states.contains(WidgetState.selected);
|
||||||
return IconThemeData(
|
return IconThemeData(
|
||||||
color: isSelected
|
color: isSelected ? selectedColor : unselectedColor,
|
||||||
? colorScheme.onPrimaryContainer
|
|
||||||
: colorScheme.onSurfaceVariant,
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import 'package:meshcore_open/l10n/l10n.dart';
|
|||||||
import 'package:meshcore_open/screens/companion_radio_stats_screen.dart';
|
import 'package:meshcore_open/screens/companion_radio_stats_screen.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
import 'mesh_ui.dart';
|
||||||
|
|
||||||
void pushCompanionRadioStatsScreen(BuildContext context) {
|
void pushCompanionRadioStatsScreen(BuildContext context) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -140,12 +143,12 @@ class AirActivityDotState extends State<AirActivityDot> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final on = widget.active && _blink;
|
final on = widget.active && _blink;
|
||||||
return Icon(
|
final scheme = Theme.of(context).colorScheme;
|
||||||
Icons.circle,
|
return PulseDot(
|
||||||
size: 12,
|
color: on ? MeshPalette.blue : scheme.outline,
|
||||||
color: on ? scheme.primary : scheme.outline,
|
size: 11,
|
||||||
|
animate: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import '../l10n/contact_localization.dart';
|
|||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import 'routing_sheet.dart';
|
import 'routing_sheet.dart';
|
||||||
|
|
||||||
@@ -269,26 +271,40 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
final repeater = _resolveRepeater(connector);
|
final repeater = _resolveRepeater(connector);
|
||||||
final isFloodMode = repeater.pathOverride == -1;
|
final isFloodMode = repeater.pathOverride == -1;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.cell_tower, color: Theme.of(context).colorScheme.tertiary),
|
AvatarCircle(
|
||||||
const SizedBox(width: 8),
|
name: repeater.name,
|
||||||
|
size: 40,
|
||||||
|
color: MeshPalette.warn,
|
||||||
|
icon: Icons.cell_tower,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(l10n.login_repeaterLogin),
|
Text(
|
||||||
|
l10n.login_repeaterLogin,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
repeater.name,
|
repeater.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.normal,
|
fontWeight: FontWeight.normal,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -319,14 +335,14 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.error,
|
Icons.error,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: scheme.error,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_loginError!,
|
_loginError!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: scheme.error,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -341,7 +357,6 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l10n.login_password,
|
labelText: l10n.login_password,
|
||||||
hintText: l10n.login_enterPassword,
|
hintText: l10n.login_enterPassword,
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
prefixIcon: const Icon(Icons.lock),
|
prefixIcon: const Icon(Icons.lock),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
@@ -390,9 +405,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
l10n.login_routing,
|
l10n.login_routing,
|
||||||
style: const TextStyle(
|
style: MeshTheme.accentLabel(
|
||||||
fontSize: 12,
|
color: scheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -421,7 +436,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
Icons.auto_mode,
|
Icons.auto_mode,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: !isFloodMode
|
color: !isFloodMode
|
||||||
? Theme.of(context).primaryColor
|
? scheme.primary
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -444,7 +459,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
Icons.waves,
|
Icons.waves,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: isFloodMode
|
color: isFloodMode
|
||||||
? Theme.of(context).primaryColor
|
? scheme.primary
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -468,7 +483,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
repeater.pathLabel(context.l10n),
|
repeater.pathLabel(context.l10n),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -502,7 +517,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
|||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: Theme.of(context).colorScheme.onPrimary,
|
color: scheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import '../l10n/contact_localization.dart';
|
|||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
import '../widgets/mesh_ui.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../helpers/snack_bar_builder.dart';
|
import '../helpers/snack_bar_builder.dart';
|
||||||
import 'routing_sheet.dart';
|
import 'routing_sheet.dart';
|
||||||
@@ -226,26 +228,40 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final connector = context.watch<MeshCoreConnector>();
|
final connector = context.watch<MeshCoreConnector>();
|
||||||
final repeater = _resolveRepeater(connector);
|
final repeater = _resolveRepeater(connector);
|
||||||
final isFloodMode = repeater.pathOverride == -1;
|
final isFloodMode = repeater.pathOverride == -1;
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.group, color: Theme.of(context).colorScheme.secondary),
|
AvatarCircle(
|
||||||
const SizedBox(width: 8),
|
name: repeater.name,
|
||||||
|
size: 40,
|
||||||
|
color: MeshPalette.magenta,
|
||||||
|
icon: Icons.group,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(l10n.login_roomLogin),
|
Text(
|
||||||
|
l10n.login_roomLogin,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
repeater.name,
|
repeater.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.normal,
|
fontWeight: FontWeight.normal,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -275,7 +291,6 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l10n.login_password,
|
labelText: l10n.login_password,
|
||||||
hintText: l10n.login_enterPassword,
|
hintText: l10n.login_enterPassword,
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
prefixIcon: const Icon(Icons.lock),
|
prefixIcon: const Icon(Icons.lock),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
@@ -319,9 +334,9 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
l10n.login_routing,
|
l10n.login_routing,
|
||||||
style: const TextStyle(
|
style: MeshTheme.accentLabel(
|
||||||
fontSize: 12,
|
color: scheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -350,7 +365,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||||||
Icons.auto_mode,
|
Icons.auto_mode,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: !isFloodMode
|
color: !isFloodMode
|
||||||
? Theme.of(context).primaryColor
|
? scheme.primary
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -373,7 +388,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||||||
Icons.waves,
|
Icons.waves,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: isFloodMode
|
color: isFloodMode
|
||||||
? Theme.of(context).primaryColor
|
? scheme.primary
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -397,7 +412,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||||||
repeater.pathLabel(context.l10n),
|
repeater.pathLabel(context.l10n),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -431,7 +446,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
|||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: Theme.of(context).colorScheme.onPrimary,
|
color: scheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
|
||||||
class SignalUi {
|
class SignalUi {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
@@ -12,27 +13,27 @@ SignalUi signalUiForStrengthTier(int tier) {
|
|||||||
case 0:
|
case 0:
|
||||||
return const SignalUi(
|
return const SignalUi(
|
||||||
icon: Icons.signal_cellular_4_bar,
|
icon: Icons.signal_cellular_4_bar,
|
||||||
color: Colors.green,
|
color: MeshPalette.signal,
|
||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
return const SignalUi(
|
return const SignalUi(
|
||||||
icon: Icons.signal_cellular_alt,
|
icon: Icons.signal_cellular_alt,
|
||||||
color: Colors.lightGreen,
|
color: MeshPalette.signalDim,
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return const SignalUi(
|
return const SignalUi(
|
||||||
icon: Icons.signal_cellular_alt_2_bar,
|
icon: Icons.signal_cellular_alt_2_bar,
|
||||||
color: Colors.amber,
|
color: MeshPalette.warn,
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return const SignalUi(
|
return const SignalUi(
|
||||||
icon: Icons.signal_cellular_alt_1_bar,
|
icon: Icons.signal_cellular_alt_1_bar,
|
||||||
color: Colors.orange,
|
color: MeshPalette.warnDim,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return const SignalUi(
|
return const SignalUi(
|
||||||
icon: Icons.signal_cellular_alt_1_bar,
|
icon: Icons.signal_cellular_alt_1_bar,
|
||||||
color: Colors.red,
|
color: MeshPalette.alert,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import '../connector/meshcore_connector.dart';
|
|||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
import 'mesh_ui.dart';
|
||||||
import 'signal_ui.dart';
|
import 'signal_ui.dart';
|
||||||
|
|
||||||
Contact? _getRepeaterPrefixMatchNearLocation(
|
Contact? _getRepeaterPrefixMatchNearLocation(
|
||||||
@@ -218,10 +220,6 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
|||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final repeater = directBestRepeaters[index];
|
final repeater = directBestRepeaters[index];
|
||||||
final snrUi = snrUiFromSNR(
|
|
||||||
repeater.snr,
|
|
||||||
widget.connector.currentSf,
|
|
||||||
);
|
|
||||||
final allContacts = widget.connector.allContacts;
|
final allContacts = widget.connector.allContacts;
|
||||||
|
|
||||||
final selfLat = widget.connector.selfLatitude;
|
final selfLat = widget.connector.selfLatitude;
|
||||||
@@ -242,22 +240,47 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final name = contact?.name;
|
final name = contact?.name;
|
||||||
|
final hex = repeater.pubkeyFirstByte
|
||||||
|
.toRadixString(16)
|
||||||
|
.padLeft(2, '0');
|
||||||
|
final snrColor = MeshTheme.snrColor(
|
||||||
|
repeater.snr,
|
||||||
|
blocked: false,
|
||||||
|
);
|
||||||
|
|
||||||
return Column(
|
return Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(
|
||||||
ListTile(
|
horizontal: 16,
|
||||||
leading: Icon(snrUi.icon, color: snrUi.color),
|
vertical: 10,
|
||||||
title: Text(
|
),
|
||||||
name ??
|
child: Row(
|
||||||
repeater.pubkeyFirstByte
|
children: [
|
||||||
.toRadixString(16)
|
AvatarCircle(
|
||||||
.padLeft(2, '0'),
|
name: name ?? hex,
|
||||||
|
size: 36,
|
||||||
|
color: snrColor,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
const SizedBox(width: 12),
|
||||||
'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}',
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name ?? hex,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${repeater.snr.toStringAsFixed(1)} dB • ${_formatLastUpdated(repeater.lastUpdated)}',
|
||||||
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 11,
|
||||||
|
color: snrColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../models/app_settings.dart';
|
|||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../services/app_settings_service.dart';
|
import '../services/app_settings_service.dart';
|
||||||
import '../services/map_tile_cache_service.dart';
|
import '../services/map_tile_cache_service.dart';
|
||||||
|
import 'themed_map_tile_layer.dart';
|
||||||
|
|
||||||
class TelemetryLocationMap extends StatefulWidget {
|
class TelemetryLocationMap extends StatefulWidget {
|
||||||
final double latitude;
|
final double latitude;
|
||||||
@@ -114,13 +115,7 @@ class _TelemetryLocationMapState extends State<TelemetryLocationMap> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
TileLayer(
|
ThemedMapTileLayer(tileCache: tileCache),
|
||||||
urlTemplate: kMapTileUrlTemplate,
|
|
||||||
tileProvider: tileCache.tileProvider,
|
|
||||||
userAgentPackageName:
|
|
||||||
MapTileCacheService.userAgentPackageName,
|
|
||||||
maxZoom: 19,
|
|
||||||
),
|
|
||||||
MarkerLayer(
|
MarkerLayer(
|
||||||
markers: [
|
markers: [
|
||||||
...contacts.map(_buildContactMarker),
|
...contacts.map(_buildContactMarker),
|
||||||
|
|||||||
@@ -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 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
|
||||||
class UnreadBadge extends StatelessWidget {
|
class UnreadBadge extends StatelessWidget {
|
||||||
final int count;
|
final int count;
|
||||||
|
|
||||||
@@ -9,17 +11,18 @@ class UnreadBadge extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final display = count > 9999 ? '9999+' : count.toString();
|
final display = count > 9999 ? '9999+' : count.toString();
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.redAccent,
|
color: MeshPalette.blue.withValues(alpha: 0.18),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||||
|
border: Border.all(color: MeshPalette.blue.withValues(alpha: 0.45)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
display,
|
display,
|
||||||
style: const TextStyle(
|
style: MeshTheme.mono(
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
|
color: MeshPalette.blue,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,30 +1,49 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../theme/mesh_theme.dart';
|
||||||
|
|
||||||
class UnreadDivider extends StatelessWidget {
|
class UnreadDivider extends StatelessWidget {
|
||||||
const UnreadDivider({super.key});
|
const UnreadDivider({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = Theme.of(context).colorScheme.primary;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final color = scheme.primary;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: Divider(color: color)),
|
Expanded(
|
||||||
Padding(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
height: 1,
|
||||||
|
color: color.withValues(alpha: 0.25),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.35)),
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
context.l10n.chat_newMessages,
|
context.l10n.chat_newMessages,
|
||||||
style: TextStyle(
|
style: MeshTheme.mono(
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
color: color,
|
color: color,
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(child: Divider(color: color)),
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 1,
|
||||||
|
color: color.withValues(alpha: 0.25),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
flserial
|
flserial
|
||||||
jni
|
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
+698
-1
@@ -1 +1,698 @@
|
|||||||
{}
|
{
|
||||||
|
"bg": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"de": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"es": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"fr": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"hu": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"it": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ja": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ko": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"nl": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"pl": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"pt": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ru": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"sk": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"sl": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"sv": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"uk": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
],
|
||||||
|
|
||||||
|
"zh": [
|
||||||
|
"map_searchHint",
|
||||||
|
"map_activity",
|
||||||
|
"map_online",
|
||||||
|
"map_recent",
|
||||||
|
"map_stale",
|
||||||
|
"map_visible",
|
||||||
|
"map_hidden",
|
||||||
|
"map_centerOnNode",
|
||||||
|
"map_details",
|
||||||
|
"map_noGps",
|
||||||
|
"map_noResults",
|
||||||
|
"pathMap_viewSingle",
|
||||||
|
"pathMap_viewCombined",
|
||||||
|
"pathMap_play",
|
||||||
|
"pathMap_pause",
|
||||||
|
"pathMap_replay",
|
||||||
|
"pathMap_stepBack",
|
||||||
|
"pathMap_stepForward",
|
||||||
|
"pathMap_animationOn",
|
||||||
|
"pathMap_animationOff",
|
||||||
|
"pathMap_hopOf",
|
||||||
|
"pathMap_observedPaths",
|
||||||
|
"pathMap_primary",
|
||||||
|
"pathMap_alternate",
|
||||||
|
"pathMap_hopCount",
|
||||||
|
"pathMap_gpsCount",
|
||||||
|
"pathMap_legendShared",
|
||||||
|
"pathMap_legendEstimated",
|
||||||
|
"pathMap_sharedNodeCount",
|
||||||
|
"pathMap_partialAnimation",
|
||||||
|
"pathMap_showAllPaths",
|
||||||
|
"pathMap_hidePath",
|
||||||
|
"pathMap_showPath",
|
||||||
|
"pathMap_collapsePanel",
|
||||||
|
"pathMap_expandPanel",
|
||||||
|
"pathMap_noLocation",
|
||||||
|
"pathMap_followPacket",
|
||||||
|
"pathMap_unfollowPacket"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
flserial
|
flserial
|
||||||
flutter_local_notifications_windows
|
flutter_local_notifications_windows
|
||||||
jni
|
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
Reference in New Issue
Block a user