mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-28 05:07:31 +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:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user