mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-08 09:43:23 +10:00
merge zjs81/dev: resolve conflicting implementations
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class BatteryUi {
|
||||
final IconData icon;
|
||||
@@ -10,19 +11,19 @@ class BatteryUi {
|
||||
|
||||
BatteryUi batteryUiForPercent(int? percent) {
|
||||
if (percent == null) {
|
||||
return const BatteryUi(Icons.battery_unknown, Colors.grey);
|
||||
return const BatteryUi(Icons.battery_unknown, null);
|
||||
}
|
||||
|
||||
final p = percent.clamp(0, 100);
|
||||
|
||||
return switch (p) {
|
||||
<= 5 => const BatteryUi(Icons.battery_alert, Colors.redAccent),
|
||||
<= 15 => const BatteryUi(Icons.battery_0_bar, Colors.redAccent),
|
||||
<= 30 => const BatteryUi(Icons.battery_1_bar, Colors.orange),
|
||||
<= 45 => const BatteryUi(Icons.battery_2_bar, Colors.amber),
|
||||
<= 60 => const BatteryUi(Icons.battery_3_bar, Colors.lightGreen),
|
||||
<= 80 => const BatteryUi(Icons.battery_5_bar, Colors.green),
|
||||
_ => const BatteryUi(Icons.battery_full, Colors.green),
|
||||
<= 5 => const BatteryUi(Icons.battery_alert, MeshPalette.alert),
|
||||
<= 15 => const BatteryUi(Icons.battery_0_bar, MeshPalette.alert),
|
||||
<= 30 => const BatteryUi(Icons.battery_1_bar, MeshPalette.warn),
|
||||
<= 45 => const BatteryUi(Icons.battery_2_bar, MeshPalette.warn),
|
||||
<= 60 => const BatteryUi(Icons.battery_3_bar, null),
|
||||
<= 80 => const BatteryUi(Icons.battery_5_bar, null),
|
||||
_ => const BatteryUi(Icons.battery_full, MeshPalette.signal),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,9 +77,9 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: batteryUi.color,
|
||||
),
|
||||
maxLines: 1,
|
||||
|
||||
@@ -86,7 +86,7 @@ class ByteCountedTextField extends StatelessWidget {
|
||||
final counterColor = ratio > errorThreshold
|
||||
? Theme.of(context).colorScheme.error
|
||||
: ratio > warningThreshold
|
||||
? Colors.orange
|
||||
? Theme.of(context).colorScheme.tertiary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
return Column(
|
||||
@@ -118,8 +118,9 @@ class ByteCountedTextField extends StatelessWidget {
|
||||
textInputAction: textInputAction,
|
||||
onSubmitted: onSubmitted,
|
||||
),
|
||||
if (showCounter)
|
||||
Padding(
|
||||
Opacity(
|
||||
opacity: showCounter ? 1 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4, right: 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
@@ -129,6 +130,7 @@ class ByteCountedTextField extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import 'mesh_ui.dart';
|
||||
import 'signal_ui.dart';
|
||||
|
||||
/// A reusable tile widget for displaying a MeshCore device in a list
|
||||
/// A MeshCard-based row for displaying a scanned BLE device.
|
||||
/// Shows an AvatarCircle (router icon, deterministic hue from device name),
|
||||
/// device name, mono MAC address, mono RSSI dBm, and SignalBars on the right.
|
||||
/// While connecting, shows a small progress ring instead of signal bars.
|
||||
class DeviceTile extends StatelessWidget {
|
||||
final ScanResult scanResult;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onTap;
|
||||
final bool isConnecting;
|
||||
|
||||
const DeviceTile({super.key, required this.scanResult, required this.onTap});
|
||||
const DeviceTile({
|
||||
super.key,
|
||||
required this.scanResult,
|
||||
required this.onTap,
|
||||
this.isConnecting = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -17,23 +30,12 @@ class DeviceTile extends StatelessWidget {
|
||||
final name = device.platformName.isNotEmpty
|
||||
? device.platformName
|
||||
: scanResult.advertisementData.advName;
|
||||
final displayName = name.isNotEmpty
|
||||
? name
|
||||
: context.l10n.common_unknownDevice;
|
||||
final mac = device.remoteId.toString();
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return ListTile(
|
||||
leading: _buildSignalIcon(rssi),
|
||||
title: Text(
|
||||
name.isNotEmpty ? name : context.l10n.common_unknownDevice,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(device.remoteId.toString()),
|
||||
trailing: ElevatedButton(
|
||||
onPressed: onTap,
|
||||
child: Text(context.l10n.common_connect),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSignalIcon(int rssi) {
|
||||
final tier = rssi >= -60
|
||||
? 0
|
||||
: rssi >= -70
|
||||
@@ -45,15 +47,70 @@ class DeviceTile extends StatelessWidget {
|
||||
: 4;
|
||||
final signalUi = signalUiForStrengthTier(tier);
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(signalUi.icon, color: signalUi.color),
|
||||
Text(
|
||||
'$rssi dBm',
|
||||
style: TextStyle(fontSize: 10, color: signalUi.color),
|
||||
),
|
||||
],
|
||||
return MeshCard(
|
||||
onTap: onTap == null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap!();
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
AvatarCircle(name: displayName, size: 42, icon: Icons.router),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
mac,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (isConnecting)
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: scheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Icon(signalUi.icon, size: 16, color: signalUi.color),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'$rssi dBm',
|
||||
style: MeshTheme.mono(fontSize: 10, color: signalUi.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,31 +29,65 @@ class FeatureToggleRow extends StatefulWidget {
|
||||
class _FeatureToggleRow extends State<FeatureToggleRow> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
title: Text(widget.title),
|
||||
subtitle: Text(widget.subtitle),
|
||||
value: widget.value,
|
||||
onChanged: widget.onChanged,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.subtitle,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.hasRefreshing)
|
||||
IconButton(
|
||||
icon: widget.isRefreshing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: widget.isRefreshing ? null : widget.onRefresh,
|
||||
tooltip: widget.refreshTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
const SizedBox(width: 8),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Switch(value: widget.value, onChanged: widget.onChanged),
|
||||
if (widget.hasRefreshing) ...[
|
||||
const SizedBox(width: 4),
|
||||
widget.isRefreshing
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.8,
|
||||
color: scheme.primary,
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
onPressed: widget.onRefresh,
|
||||
tooltip: widget.refreshTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class EmojiPicker extends StatelessWidget {
|
||||
final Function(String) onEmojiSelected;
|
||||
@@ -257,7 +258,11 @@ class EmojiPicker extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 28),
|
||||
style: MeshTheme.emoji(),
|
||||
textHeightBehavior: const TextHeightBehavior(
|
||||
applyHeightToFirstAscent: false,
|
||||
applyHeightToLastDescent: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -298,7 +303,12 @@ class EmojiPicker extends StatelessWidget {
|
||||
child: Center(
|
||||
child: Text(
|
||||
emojis[index],
|
||||
style: const TextStyle(fontSize: 28),
|
||||
style: MeshTheme.emoji(),
|
||||
textHeightBehavior:
|
||||
const TextHeightBehavior(
|
||||
applyHeightToFirstAscent: false,
|
||||
applyHeightToLastDescent: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A centered empty state display with icon, title, and optional subtitle/action.
|
||||
class EmptyState extends StatelessWidget {
|
||||
/// Features a tinted icon circle, fade+slide entrance animation, and clear
|
||||
/// typography hierarchy using the MeshCore design system.
|
||||
class EmptyState extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
@@ -15,25 +17,97 @@ class EmptyState extends StatelessWidget {
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EmptyState> createState() => _EmptyStateState();
|
||||
}
|
||||
|
||||
class _EmptyStateState extends State<EmptyState>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 420),
|
||||
);
|
||||
late final CurvedAnimation _curve = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_curve.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: TextStyle(fontSize: 16, color: Colors.grey[600])),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
textAlign: TextAlign.center,
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return FadeTransition(
|
||||
opacity: _curve,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 0.06),
|
||||
end: Offset.zero,
|
||||
).animate(_curve),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.primary.withValues(alpha: 0.08),
|
||||
border: Border.all(
|
||||
color: scheme.primary.withValues(alpha: 0.18),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
size: 36,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onSurface,
|
||||
letterSpacing: -0.1,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (widget.subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: scheme.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (widget.action != null) ...[
|
||||
const SizedBox(height: 28),
|
||||
widget.action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
if (action != null) ...[const SizedBox(height: 24), action!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,7 +180,10 @@ class _GifPickerState extends State<GifPicker> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.gifPicker_poweredBy,
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -197,11 +200,18 @@ class _GifPickerState extends State<GifPicker> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
@@ -219,11 +229,18 @@ class _GifPickerState extends State<GifPicker> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.gifPicker_noGifsFound,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../helpers/chat_scroll_controller.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class JumpToBottomButton extends StatelessWidget {
|
||||
final ChatScrollController scrollController;
|
||||
@@ -8,6 +10,7 @@ class JumpToBottomButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: scrollController.showJumpToBottom,
|
||||
builder: (context, show, _) {
|
||||
@@ -15,9 +18,33 @@ class JumpToBottomButton extends StatelessWidget {
|
||||
return Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton.small(
|
||||
onPressed: scrollController.jumpToBottom,
|
||||
child: const Icon(Icons.keyboard_arrow_down),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: scrollController.jumpToBottom,
|
||||
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.surfaceContainerHigh.withValues(alpha: 0.92),
|
||||
border: Border.all(color: scheme.outlineVariant, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 22,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,159 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class MessageStatusIcon extends StatelessWidget {
|
||||
import '../l10n/l10n.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class MessageStatusIcon extends StatefulWidget {
|
||||
final bool isAcked;
|
||||
final bool isFailed;
|
||||
final bool isPending;
|
||||
final bool isRepeated;
|
||||
final double size;
|
||||
|
||||
/// Base tint for the sent/sending state. On a colored (outgoing) bubble a
|
||||
/// plain grey tick is nearly invisible, so callers can pass the bubble's own
|
||||
/// meta/text color for contrast. Falls back to [ColorScheme.onSurfaceVariant].
|
||||
final Color? onColor;
|
||||
|
||||
const MessageStatusIcon({
|
||||
super.key,
|
||||
required this.isAcked,
|
||||
this.isFailed = false,
|
||||
this.isPending = false,
|
||||
this.isRepeated = false,
|
||||
this.size = 14,
|
||||
this.onColor,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MessageStatusIcon> createState() => _MessageStatusIconState();
|
||||
}
|
||||
|
||||
class _MessageStatusIconState extends State<MessageStatusIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
);
|
||||
if (widget.isPending) _controller.repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MessageStatusIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isPending && !_controller.isAnimating) {
|
||||
_controller.repeat();
|
||||
} else if (!widget.isPending && _controller.isAnimating) {
|
||||
_controller
|
||||
..stop()
|
||||
..reset();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final double size = widget.size;
|
||||
final Color baseColor = widget.onColor ?? colorScheme.onSurfaceVariant;
|
||||
|
||||
if (widget.isFailed) {
|
||||
return Semantics(
|
||||
label: l10n.messageStatus_failed,
|
||||
child: Icon(Icons.cancel, size: size, color: colorScheme.error),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.isPending) {
|
||||
return Semantics(
|
||||
label: l10n.messageStatus_pending,
|
||||
child: _SendingDots(
|
||||
controller: _controller,
|
||||
color: baseColor,
|
||||
size: size,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final bool delivered = widget.isAcked || widget.isRepeated;
|
||||
final String label = widget.isRepeated
|
||||
? l10n.messageStatus_repeated
|
||||
: widget.isAcked
|
||||
? l10n.messageStatus_delivered
|
||||
: l10n.messageStatus_sent;
|
||||
// Use palette colors: tertiary (warn/amber) for acked/repeated, base for sent.
|
||||
final Color color = delivered
|
||||
? MeshPalette.signal.withValues(alpha: 0.9)
|
||||
: baseColor;
|
||||
|
||||
return Semantics(
|
||||
label: label,
|
||||
child: delivered
|
||||
? SvgPicture.asset(
|
||||
'assets/icons/done_all.svg',
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
|
||||
)
|
||||
: Icon(Icons.done, size: size, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Three dots that pulse left-to-right while a message is in flight.
|
||||
class _SendingDots extends StatelessWidget {
|
||||
final AnimationController controller;
|
||||
final Color color;
|
||||
final double size;
|
||||
|
||||
const _SendingDots({
|
||||
required this.controller,
|
||||
required this.color,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isFailed) {
|
||||
return Icon(Icons.cancel, size: size, color: Colors.red);
|
||||
}
|
||||
|
||||
final Color color;
|
||||
if (isAcked) {
|
||||
color = Colors.green;
|
||||
} else {
|
||||
color = Colors.grey;
|
||||
}
|
||||
|
||||
return SvgPicture.asset(
|
||||
'assets/icons/done_all.svg',
|
||||
width: size,
|
||||
final double dot = (size * 0.24).clamp(2.0, 4.0);
|
||||
return SizedBox(
|
||||
height: size,
|
||||
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
|
||||
child: AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: List.generate(3, (i) {
|
||||
final double phase = (controller.value - i * 0.18) % 1.0;
|
||||
final double t = phase < 0.5 ? phase * 2 : (1 - phase) * 2;
|
||||
final double opacity = 0.25 + 0.75 * t.clamp(0.0, 1.0);
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: dot * 0.28),
|
||||
child: Container(
|
||||
width: dot,
|
||||
height: dot,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: opacity),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../helpers/path_helper.dart';
|
||||
import '../l10n/contact_localization.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
|
||||
class PathEditorSheet extends StatefulWidget {
|
||||
final List<Contact> availableContacts;
|
||||
final List<int> initialPath;
|
||||
|
||||
const PathEditorSheet({
|
||||
super.key,
|
||||
required this.availableContacts,
|
||||
this.initialPath = const [],
|
||||
});
|
||||
|
||||
static Future<Uint8List?> show(
|
||||
BuildContext context, {
|
||||
required List<Contact> availableContacts,
|
||||
List<int> initialPath = const [],
|
||||
}) {
|
||||
return showModalBottomSheet<Uint8List>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
heightFactor: 0.9,
|
||||
child: PathEditorSheet(
|
||||
availableContacts: availableContacts,
|
||||
initialPath: initialPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<PathEditorSheet> createState() => _PathEditorSheetState();
|
||||
}
|
||||
|
||||
class _Hop {
|
||||
final int id;
|
||||
final int byte;
|
||||
|
||||
const _Hop(this.id, this.byte);
|
||||
}
|
||||
|
||||
class _PathEditorSheetState extends State<PathEditorSheet> {
|
||||
static const int _maxHops = 64;
|
||||
|
||||
final List<_Hop> _hops = [];
|
||||
final _hexController = TextEditingController();
|
||||
String? _hexError;
|
||||
bool _syncingHex = false;
|
||||
String _search = '';
|
||||
int _nextHopId = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final byte in widget.initialPath) {
|
||||
_hops.add(_Hop(_nextHopId++, byte));
|
||||
}
|
||||
_syncHexFromHops();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hexController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Contact> get _repeaters {
|
||||
final query = _search.trim().toLowerCase();
|
||||
return widget.availableContacts
|
||||
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
|
||||
.where((c) => c.publicKey.isNotEmpty)
|
||||
.where((c) => query.isEmpty || c.name.toLowerCase().contains(query))
|
||||
.toList()
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
}
|
||||
|
||||
void _syncHexFromHops() {
|
||||
_syncingHex = true;
|
||||
_hexController.text = PathHelper.formatPathHex(
|
||||
_hops.map((h) => h.byte).toList(),
|
||||
);
|
||||
_syncingHex = false;
|
||||
_hexError = null;
|
||||
}
|
||||
|
||||
void _onHexChanged(String text) {
|
||||
if (_syncingHex) return;
|
||||
final l10n = context.l10n;
|
||||
final tokens = text
|
||||
.split(RegExp(r'[,\s]+'))
|
||||
.where((t) => t.isNotEmpty)
|
||||
.toList();
|
||||
final invalid = tokens
|
||||
.where((t) => t.length != 2 || int.tryParse(t, radix: 16) == null)
|
||||
.toList();
|
||||
setState(() {
|
||||
if (invalid.isNotEmpty) {
|
||||
_hexError = l10n.pathEditor_invalidTokens(invalid.join(', '));
|
||||
return;
|
||||
}
|
||||
if (tokens.length > _maxHops) {
|
||||
_hexError = l10n.pathEditor_tooManyHops;
|
||||
return;
|
||||
}
|
||||
_hexError = null;
|
||||
_hops
|
||||
..clear()
|
||||
..addAll(
|
||||
tokens.map((t) => _Hop(_nextHopId++, int.parse(t, radix: 16))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _addHop(Contact contact) {
|
||||
if (_hops.length >= _maxHops) return;
|
||||
setState(() {
|
||||
_hops.add(_Hop(_nextHopId++, contact.publicKey.first));
|
||||
_syncHexFromHops();
|
||||
});
|
||||
}
|
||||
|
||||
void _removeHop(int index) {
|
||||
setState(() {
|
||||
_hops.removeAt(index);
|
||||
_syncHexFromHops();
|
||||
});
|
||||
}
|
||||
|
||||
void _reorderHop(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
final hop = _hops.removeAt(oldIndex);
|
||||
_hops.insert(newIndex, hop);
|
||||
_syncHexFromHops();
|
||||
});
|
||||
}
|
||||
|
||||
void _save() {
|
||||
Navigator.pop(
|
||||
context,
|
||||
Uint8List.fromList(_hops.map((h) => h.byte).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _hopTile(BuildContext context, int index) {
|
||||
final l10n = context.l10n;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final hop = _hops[index];
|
||||
final hex = PathHelper.hopHex(hop.byte);
|
||||
final name = PathHelper.hopName(hop.byte, widget.availableContacts);
|
||||
|
||||
return ListTile(
|
||||
key: ValueKey(hop.id),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: scheme.primaryContainer,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(fontSize: 12, color: scheme.onPrimaryContainer),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
name ?? l10n.pathEditor_unknownHop,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(hex),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
tooltip: l10n.pathEditor_removeHop,
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
onPressed: () => _removeHop(index),
|
||||
),
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 12),
|
||||
child: Icon(Icons.drag_handle),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _repeaterTile(BuildContext context, Contact contact) {
|
||||
final l10n = context.l10n;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final isRepeater = contact.type == advTypeRepeater;
|
||||
final full = _hops.length >= _maxHops;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
enabled: !full,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: isRepeater
|
||||
? scheme.primaryContainer
|
||||
: scheme.secondaryContainer,
|
||||
child: Icon(
|
||||
isRepeater ? Icons.router : Icons.meeting_room,
|
||||
size: 16,
|
||||
color: isRepeater
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
'${contact.typeLabel(l10n)} • ${PathHelper.hopHex(contact.publicKey.first)}',
|
||||
),
|
||||
trailing: const Icon(Icons.add_circle_outline),
|
||||
onTap: full ? null : () => _addHop(contact),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final repeaters = _repeaters;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.pathEditor_title, style: theme.textTheme.titleLarge),
|
||||
Text(
|
||||
l10n.pathEditor_hopCounter(_hops.length),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
children: [
|
||||
if (_hops.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(
|
||||
l10n.pathEditor_noHops,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: _hops.length,
|
||||
onReorderItem: _reorderHop,
|
||||
itemBuilder: _hopTile,
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
Text(l10n.pathEditor_addHops, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
onChanged: (value) => setState(() => _search = value),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.pathEditor_searchRepeaters,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (repeaters.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text(
|
||||
l10n.path_noRepeatersFound,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...repeaters.map((c) => _repeaterTile(context, c)),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
l10n.pathEditor_advancedHex,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: TextField(
|
||||
controller: _hexController,
|
||||
onChanged: _onHexChanged,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.pathEditor_hexLabel,
|
||||
helperText: _hexError == null
|
||||
? l10n.pathEditor_hexHelper
|
||||
: null,
|
||||
errorText: _hexError,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
onPressed: _hexError != null ? null : _save,
|
||||
child: Text(l10n.pathEditor_usePath),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/models/path_history.dart';
|
||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:meshcore_open/widgets/elements_ui.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../l10n/contact_localization.dart';
|
||||
import '../helpers/path_helper.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
import 'path_selection_dialog.dart';
|
||||
|
||||
class PathManagementDialog {
|
||||
static Future<void> show(BuildContext context, {required Contact contact}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _PathManagementDialog(contact: contact),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PathManagementDialog extends StatefulWidget {
|
||||
final Contact contact;
|
||||
|
||||
const _PathManagementDialog({required this.contact});
|
||||
|
||||
@override
|
||||
State<_PathManagementDialog> createState() => _PathManagementDialogState();
|
||||
}
|
||||
|
||||
class _PathManagementDialogState extends State<_PathManagementDialog> {
|
||||
bool _showAllPaths = false;
|
||||
|
||||
int _resolveContactIndex = -1;
|
||||
|
||||
Contact _resolveContact(MeshCoreConnector connector) {
|
||||
if (_resolveContactIndex >= 0 &&
|
||||
_resolveContactIndex < connector.contacts.length &&
|
||||
connector.contacts[_resolveContactIndex].publicKeyHex ==
|
||||
widget.contact.publicKeyHex) {
|
||||
return connector.contacts[_resolveContactIndex];
|
||||
}
|
||||
_resolveContactIndex = connector.contacts.indexWhere(
|
||||
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
|
||||
);
|
||||
if (_resolveContactIndex == -1) {
|
||||
return widget.contact;
|
||||
}
|
||||
return connector.contacts[_resolveContactIndex];
|
||||
}
|
||||
|
||||
String _formatRelativeTime(BuildContext context, DateTime? time) {
|
||||
if (time == null) return '—';
|
||||
final l10n = context.l10n;
|
||||
final diff = DateTime.now().difference(time);
|
||||
if (diff.inSeconds < 60) return l10n.time_justNow;
|
||||
if (diff.inMinutes < 60) return l10n.time_minutesAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) return l10n.time_hoursAgo(diff.inHours);
|
||||
return l10n.time_daysAgo(diff.inDays);
|
||||
}
|
||||
|
||||
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
|
||||
final l10n = context.l10n;
|
||||
if (pathBytes.isEmpty) {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(l10n.chat_pathDetailsNotAvailable),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final allContacts = connector.allContacts;
|
||||
|
||||
final formattedPath = PathHelper.formatPathHex(pathBytes);
|
||||
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.chat_fullPath),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(formattedPath),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
resolvedNames,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: context.l10n.contacts_repeaterPathTrace,
|
||||
path: Uint8List.fromList(pathBytes),
|
||||
flipPathAround: true,
|
||||
targetContact: widget.contact,
|
||||
pathHashByteWidth: connector.pathHashByteWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.contacts_pathTrace),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setCustomPath(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Contact currentContact,
|
||||
) async {
|
||||
final l10n = context.l10n;
|
||||
if (currentContact.pathLength > 0 &&
|
||||
currentContact.path.isEmpty &&
|
||||
connector.isConnected) {
|
||||
connector.getContacts();
|
||||
}
|
||||
|
||||
final pathForInput = currentContact.pathFormattedIdList(
|
||||
connector.pathHashByteWidth,
|
||||
);
|
||||
final availableContacts = connector.allContacts
|
||||
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
|
||||
.toList();
|
||||
|
||||
final result = await PathSelectionDialog.show(
|
||||
context,
|
||||
availableContacts: availableContacts,
|
||||
initialPath: pathForInput.isEmpty ? null : pathForInput,
|
||||
currentPathLabel: currentContact.pathLabel(l10n),
|
||||
onRefresh: connector.isConnected ? connector.getContacts : null,
|
||||
);
|
||||
|
||||
if (result != null && context.mounted) {
|
||||
await connector.setPathOverride(
|
||||
currentContact,
|
||||
pathLen: result.length,
|
||||
pathBytes: result,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(l10n.chat_hopsCount(result.length)),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Consumer2<MeshCoreConnector, PathHistoryService>(
|
||||
builder: (context, connector, pathService, _) {
|
||||
final currentContact = _resolveContact(connector);
|
||||
final paths = pathService.getRecentPaths(currentContact.publicKeyHex);
|
||||
|
||||
final repeatersList = List.of(connector.directRepeaters)
|
||||
..sort((a, b) => b.ranking.compareTo(a.ranking));
|
||||
|
||||
if (repeatersList.isEmpty) {
|
||||
_showAllPaths = true;
|
||||
}
|
||||
|
||||
final directRepeater = repeatersList.isEmpty
|
||||
? null
|
||||
: repeatersList.first;
|
||||
final secondDirectRepeater = repeatersList.length < 2
|
||||
? null
|
||||
: repeatersList.elementAt(1);
|
||||
final thirdDirectRepeater = repeatersList.length < 3
|
||||
? null
|
||||
: repeatersList.elementAt(2);
|
||||
|
||||
List<MapEntry<int, MapEntry<Color, PathRecord>>> pathsWithRepeaters =
|
||||
paths.map((path) {
|
||||
final isDirectRepeater =
|
||||
directRepeater != null &&
|
||||
path.pathBytes.isNotEmpty &&
|
||||
directRepeater.pubkeyFirstByte == path.pathBytes.first;
|
||||
final isSecondDirectRepeater =
|
||||
secondDirectRepeater != null &&
|
||||
path.pathBytes.isNotEmpty &&
|
||||
secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first;
|
||||
final isThirdDirectRepeater =
|
||||
thirdDirectRepeater != null &&
|
||||
path.pathBytes.isNotEmpty &&
|
||||
thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first;
|
||||
|
||||
int ranking = -1;
|
||||
Color color = Colors.grey;
|
||||
if (isDirectRepeater) {
|
||||
color = Colors.green;
|
||||
ranking = 3;
|
||||
} else if (isSecondDirectRepeater) {
|
||||
color = Colors.yellow;
|
||||
ranking = 2;
|
||||
} else if (isThirdDirectRepeater) {
|
||||
color = Colors.red;
|
||||
ranking = 1;
|
||||
} else if (path.wasFloodDiscovery) {
|
||||
color = Colors.blue;
|
||||
ranking = 0;
|
||||
}
|
||||
|
||||
return MapEntry(ranking, MapEntry(color, path));
|
||||
}).toList();
|
||||
|
||||
pathsWithRepeaters.sort((a, b) => b.key.compareTo(a.key));
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(l10n.chat_pathManagement),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.path_currentPath(currentContact.pathLabel(l10n)),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (paths.isNotEmpty) ...[
|
||||
if (repeatersList.isNotEmpty)
|
||||
FeatureToggleRow(
|
||||
title: l10n.chat_ShowAllPaths,
|
||||
subtitle: "",
|
||||
value: _showAllPaths,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_showAllPaths = val;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text(
|
||||
l10n.chat_recentAckPaths,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (pathsWithRepeaters.length >= 100) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amberAccent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
l10n.chat_pathHistoryFull,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
...pathsWithRepeaters.map((entry) {
|
||||
final path = entry.value.value;
|
||||
final color = entry.value.key;
|
||||
|
||||
if (!_showAllPaths && entry.key < 1) {
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: color,
|
||||
child: Text(
|
||||
path.routeWeight.toStringAsFixed(1),
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
l10n.chat_hopsCount(path.hopCount),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
isThreeLine: true,
|
||||
subtitle: Text(
|
||||
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}\n${path.successCount} ${l10n.chat_successes} • ${l10n.chat_score}: ${path.routeWeight.toStringAsFixed(1)}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
tooltip: l10n.chat_removePath,
|
||||
onPressed: () async {
|
||||
await pathService.removePathRecord(
|
||||
currentContact.publicKeyHex,
|
||||
path.pathBytes,
|
||||
);
|
||||
},
|
||||
),
|
||||
path.wasFloodDiscovery
|
||||
? const Icon(
|
||||
Icons.waves,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.route,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
onLongPress: () =>
|
||||
_showFullPathDialog(context, path.pathBytes),
|
||||
onTap: () async {
|
||||
if (path.pathBytes.isEmpty) {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(
|
||||
l10n.chat_pathDetailsNotAvailable,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final pathBytes = Uint8List.fromList(
|
||||
path.pathBytes,
|
||||
);
|
||||
final pathLength = path.pathBytes.length;
|
||||
|
||||
await connector.setPathOverride(
|
||||
currentContact,
|
||||
pathLen: pathLength,
|
||||
pathBytes: pathBytes,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(
|
||||
l10n.path_usingHopsPath(path.hopCount),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
const Divider(),
|
||||
] else ...[
|
||||
Text(l10n.chat_noPathHistoryYet),
|
||||
const Divider(),
|
||||
],
|
||||
// Flood delivery stats
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final floodStats = pathService.getFloodStats(
|
||||
currentContact.publicKeyHex,
|
||||
);
|
||||
if (floodStats == null ||
|
||||
(floodStats.successCount == 0 &&
|
||||
floodStats.failureCount == 0)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.waves, size: 16),
|
||||
),
|
||||
title: const Text(
|
||||
'Flood Mode',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${floodStats.successCount} ${l10n.chat_successes} / ${floodStats.failureCount} failures'
|
||||
'${floodStats.lastTripTimeMs > 0 ? ' • ${(floodStats.lastTripTimeMs / 1000).toStringAsFixed(2)}s' : ''}'
|
||||
'${floodStats.lastUsed != null ? ' • ${_formatRelativeTime(context, floodStats.lastUsed!)}' : ''}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.chat_pathActions,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.purple,
|
||||
child: Icon(Icons.edit_road, size: 16),
|
||||
),
|
||||
title: Text(
|
||||
l10n.chat_setCustomPath,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.chat_setCustomPathSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await _setCustomPath(context, connector, currentContact);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.orange,
|
||||
child: Icon(Icons.clear_all, size: 16),
|
||||
),
|
||||
title: Text(
|
||||
l10n.chat_clearPath,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.chat_clearPathSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await connector.clearContactPath(currentContact);
|
||||
if (!context.mounted) return;
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(l10n.chat_pathCleared),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.waves, size: 16),
|
||||
),
|
||||
title: Text(
|
||||
l10n.chat_forceFloodMode,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.chat_floodModeSubtitle,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
onTap: () async {
|
||||
await connector.setPathOverride(
|
||||
currentContact,
|
||||
pathLen: -1,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(l10n.chat_floodModeEnabled),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.common_close),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../l10n/contact_localization.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
|
||||
class PathSelectionDialog extends StatefulWidget {
|
||||
final List<Contact> availableContacts;
|
||||
final String title;
|
||||
final String? initialPath;
|
||||
final String? currentPathLabel;
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
const PathSelectionDialog({
|
||||
super.key,
|
||||
required this.availableContacts,
|
||||
required this.title,
|
||||
this.initialPath,
|
||||
this.currentPathLabel,
|
||||
this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PathSelectionDialog> createState() => _PathSelectionDialogState();
|
||||
|
||||
static Future<Uint8List?> show(
|
||||
BuildContext context, {
|
||||
required List<Contact> availableContacts,
|
||||
String? title,
|
||||
String? initialPath,
|
||||
String? currentPathLabel,
|
||||
VoidCallback? onRefresh,
|
||||
}) {
|
||||
return showDialog<Uint8List?>(
|
||||
context: context,
|
||||
builder: (context) => PathSelectionDialog(
|
||||
availableContacts: availableContacts,
|
||||
title: title ?? context.l10n.path_enterCustomPath,
|
||||
initialPath: initialPath,
|
||||
currentPathLabel: currentPathLabel,
|
||||
onRefresh: onRefresh,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
||||
late TextEditingController _controller;
|
||||
final List<Contact> _selectedContacts = [];
|
||||
List<Contact> _validContacts = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialPath ?? '');
|
||||
_filterValidContacts();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PathSelectionDialog oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.availableContacts != oldWidget.availableContacts) {
|
||||
_filterValidContacts();
|
||||
}
|
||||
}
|
||||
|
||||
void _filterValidContacts() {
|
||||
_validContacts = widget.availableContacts
|
||||
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _updateTextFromContacts() {
|
||||
final pathParts = _selectedContacts
|
||||
.map((contact) {
|
||||
if (contact.publicKeyHex.length >= 2) {
|
||||
return contact.publicKeyHex.substring(0, 2);
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
_controller.text = pathParts.join(',');
|
||||
}
|
||||
|
||||
void _toggleContact(Contact contact) {
|
||||
setState(() {
|
||||
if (_selectedContacts.contains(contact)) {
|
||||
_selectedContacts.remove(contact);
|
||||
} else {
|
||||
_selectedContacts.add(contact);
|
||||
}
|
||||
_updateTextFromContacts();
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
setState(() {
|
||||
_selectedContacts.clear();
|
||||
_controller.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _validateAndSubmit() async {
|
||||
final l10n = context.l10n;
|
||||
final path = _controller.text.trim().toUpperCase();
|
||||
if (path.isEmpty) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse comma-separated hex prefixes
|
||||
final pathIds = path
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
final pathBytesList = <int>[];
|
||||
final invalidPrefixes = <String>[];
|
||||
|
||||
for (final id in pathIds) {
|
||||
if (id.length < 2) {
|
||||
invalidPrefixes.add(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
final prefix = id.substring(0, 2);
|
||||
try {
|
||||
final byte = int.parse(prefix, radix: 16);
|
||||
pathBytesList.add(byte);
|
||||
} catch (e) {
|
||||
invalidPrefixes.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Show error for invalid prefixes
|
||||
if (invalidPrefixes.isNotEmpty) {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(l10n.path_invalidHexPrefixes(invalidPrefixes.join(", "))),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: Colors.red,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check max path length (64 hops)
|
||||
if (pathBytesList.length > 64) {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(l10n.path_tooLong),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: Colors.red,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathBytesList.isNotEmpty && mounted) {
|
||||
Navigator.pop(context, Uint8List.fromList(pathBytesList));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.currentPathLabel != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.path_currentPathLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (widget.onRefresh != null)
|
||||
TextButton.icon(
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(l10n.common_reload),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
widget.currentPathLabel!,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
l10n.path_hexPrefixInstructions,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.path_hexPrefixExample,
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.path_labelHexPrefixes,
|
||||
hintText: l10n.path_hexPrefixExample,
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: l10n.path_helperMaxHops,
|
||||
),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
maxLength: 191, // 64 hops * 2 chars + 63 commas
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.path_selectFromContacts,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_selectedContacts.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _clearSelection,
|
||||
child: Text(l10n.common_clear),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_validContacts.isEmpty) ...[
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.path_noRepeatersFound,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.path_customPathsRequire,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _validContacts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = _validContacts[index];
|
||||
final isSelected = _selectedContacts.contains(contact);
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: isSelected
|
||||
? Colors.green
|
||||
: (contact.type == 2
|
||||
? Colors.blue
|
||||
: Colors.purple),
|
||||
child: Icon(
|
||||
contact.type == 2
|
||||
? Icons.router
|
||||
: Icons.meeting_room,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
contact.name,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${contact.typeLabel(l10n)} • ${contact.publicKeyHex.substring(0, 2)}',
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
trailing: isSelected
|
||||
? const Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.green,
|
||||
)
|
||||
: const Icon(Icons.add_circle_outline),
|
||||
onTap: () => _toggleContact(contact),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _validateAndSubmit,
|
||||
child: Text(l10n.path_setPath),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class QuickSwitchBar extends StatelessWidget {
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
final int contactsUnreadCount;
|
||||
final int channelsUnreadCount;
|
||||
final bool highContrast;
|
||||
|
||||
const QuickSwitchBar({
|
||||
super.key,
|
||||
@@ -15,6 +17,7 @@ class QuickSwitchBar extends StatelessWidget {
|
||||
required this.onDestinationSelected,
|
||||
this.contactsUnreadCount = 0,
|
||||
this.channelsUnreadCount = 0,
|
||||
this.highContrast = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -22,6 +25,14 @@ class QuickSwitchBar extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final labelStyle = theme.textTheme.labelMedium ?? const TextStyle();
|
||||
final background = highContrast ? MapPalette.panelDark : Colors.transparent;
|
||||
final selectedColor = highContrast
|
||||
? MapPalette.textPrimary
|
||||
: colorScheme.onPrimary;
|
||||
final unselectedColor = highContrast
|
||||
? MapPalette.textSecondary
|
||||
: colorScheme.onSurfaceVariant;
|
||||
final indicator = highContrast ? MapPalette.selected : colorScheme.primary;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -31,9 +42,11 @@ class QuickSwitchBar extends StatelessWidget {
|
||||
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
color: background,
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
color: highContrast
|
||||
? MapPalette.border
|
||||
: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: NavigationBarTheme(
|
||||
@@ -41,22 +54,18 @@ class QuickSwitchBar extends StatelessWidget {
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
indicatorColor: colorScheme.primaryContainer,
|
||||
indicatorColor: indicator,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||
final isSelected = states.contains(WidgetState.selected);
|
||||
return labelStyle.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
color: isSelected ? selectedColor : unselectedColor,
|
||||
);
|
||||
}),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||
final isSelected = states.contains(WidgetState.selected);
|
||||
return IconThemeData(
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
color: isSelected ? selectedColor : unselectedColor,
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -67,10 +76,12 @@ class QuickSwitchBar extends StatelessWidget {
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: _buildIconWithBadge(
|
||||
context,
|
||||
const Icon(Icons.people_outline),
|
||||
contactsUnreadCount,
|
||||
),
|
||||
selectedIcon: _buildIconWithBadge(
|
||||
context,
|
||||
const Icon(Icons.people),
|
||||
contactsUnreadCount,
|
||||
),
|
||||
@@ -78,10 +89,12 @@ class QuickSwitchBar extends StatelessWidget {
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: _buildIconWithBadge(
|
||||
context,
|
||||
const Icon(Icons.tag),
|
||||
channelsUnreadCount,
|
||||
),
|
||||
selectedIcon: _buildIconWithBadge(
|
||||
context,
|
||||
const Icon(Icons.tag),
|
||||
channelsUnreadCount,
|
||||
),
|
||||
@@ -101,26 +114,9 @@ class QuickSwitchBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIconWithBadge(Icon icon, int count) {
|
||||
Widget _buildIconWithBadge(BuildContext context, Icon icon, int count) {
|
||||
if (count <= 0) return icon;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
icon,
|
||||
Positioned(
|
||||
right: -2,
|
||||
top: -2,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.redAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final label = count > 99 ? '99+' : '$count';
|
||||
return Badge(label: Text(label), child: icon);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import 'package:meshcore_open/l10n/l10n.dart';
|
||||
import 'package:meshcore_open/screens/companion_radio_stats_screen.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../theme/mesh_theme.dart';
|
||||
import 'mesh_ui.dart';
|
||||
|
||||
void pushCompanionRadioStatsScreen(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -59,11 +62,15 @@ class _RadioStatsIconButtonState extends State<RadioStatsIconButton> {
|
||||
active: connector.radioStatsAirActivityPulse,
|
||||
);
|
||||
if (widget.compact) {
|
||||
return GestureDetector(
|
||||
onTap: () => pushCompanionRadioStatsScreen(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: dot,
|
||||
return Semantics(
|
||||
label: context.l10n.radioStats_tooltip,
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
onTap: () => pushCompanionRadioStatsScreen(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: dot,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -136,12 +143,12 @@ class AirActivityDotState extends State<AirActivityDot> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final on = widget.active && _blink;
|
||||
return Icon(
|
||||
Icons.circle,
|
||||
size: 12,
|
||||
color: on ? scheme.primary : scheme.outline,
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return PulseDot(
|
||||
color: on ? MeshPalette.blue : scheme.outline,
|
||||
size: 11,
|
||||
animate: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import '../utils/platform_info.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -10,8 +9,10 @@ import '../l10n/contact_localization.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'path_management_dialog.dart';
|
||||
import 'routing_sheet.dart';
|
||||
|
||||
class RepeaterLoginDialog extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
@@ -270,26 +271,40 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.cell_tower, color: Colors.orange),
|
||||
const SizedBox(width: 8),
|
||||
AvatarCircle(
|
||||
name: repeater.name,
|
||||
size: 40,
|
||||
color: MeshPalette.warn,
|
||||
icon: Icons.cell_tower,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.login_repeaterLogin),
|
||||
Text(
|
||||
l10n.login_repeaterLogin,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Colors.grey[600],
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -317,19 +332,12 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
Icon(Icons.error, size: 18, color: scheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_loginError!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 13,
|
||||
),
|
||||
style: TextStyle(color: scheme.error, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -342,7 +350,6 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.login_password,
|
||||
hintText: l10n.login_enterPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
@@ -365,9 +372,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) => _handleLogin(),
|
||||
autofocus:
|
||||
!PlatformInfo.isMobile &&
|
||||
_passwordController.text.isEmpty,
|
||||
autofocus: _passwordController.text.isEmpty,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CheckboxListTile(
|
||||
@@ -393,9 +398,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
children: [
|
||||
Text(
|
||||
l10n.login_routing,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
style: MeshTheme.accentLabel(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -423,9 +428,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
color: !isFloodMode ? scheme.primary : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
@@ -446,9 +449,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
color: isFloodMode ? scheme.primary : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
@@ -469,14 +470,17 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
repeater.pathLabel(context.l10n),
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
ContactRoutingSheet.show(context, contact: repeater),
|
||||
icon: const Icon(Icons.timeline, size: 18),
|
||||
label: Text(l10n.login_managePaths),
|
||||
),
|
||||
@@ -497,12 +501,12 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
color: scheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@@ -10,9 +10,11 @@ import '../l10n/contact_localization.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import '../widgets/mesh_ui.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
import 'path_management_dialog.dart';
|
||||
import 'routing_sheet.dart';
|
||||
|
||||
class RoomLoginDialog extends StatefulWidget {
|
||||
final Contact room;
|
||||
@@ -181,7 +183,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.login_failed(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -226,26 +228,40 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.group, color: Colors.purple),
|
||||
const SizedBox(width: 8),
|
||||
AvatarCircle(
|
||||
name: repeater.name,
|
||||
size: 40,
|
||||
color: MeshPalette.magenta,
|
||||
icon: Icons.group,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.login_roomLogin),
|
||||
Text(
|
||||
l10n.login_roomLogin,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Colors.grey[600],
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -275,7 +291,6 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.login_password,
|
||||
hintText: l10n.login_enterPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
@@ -319,9 +334,9 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
||||
children: [
|
||||
Text(
|
||||
l10n.login_routing,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
style: MeshTheme.accentLabel(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -349,9 +364,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
color: !isFloodMode ? scheme.primary : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
@@ -372,9 +385,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
color: isFloodMode ? scheme.primary : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
@@ -395,14 +406,17 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
repeater.pathLabel(context.l10n),
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
ContactRoutingSheet.show(context, contact: repeater),
|
||||
icon: const Icon(Icons.timeline, size: 18),
|
||||
label: Text(l10n.login_managePaths),
|
||||
),
|
||||
@@ -423,12 +437,12 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
color: scheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../helpers/path_helper.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/path_history.dart';
|
||||
import '../screens/path_trace_map.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import 'path_editor_sheet.dart';
|
||||
|
||||
enum _RoutingMode { auto, flood, manual }
|
||||
|
||||
enum _PathQuality { strong, good, fair, proven, flood, untested }
|
||||
|
||||
class ContactRoutingSheet {
|
||||
static Future<void> show(BuildContext context, {required Contact contact}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.75,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, scrollController) => _RoutingSheetBody(
|
||||
contact: contact,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoutingSheetBody extends StatefulWidget {
|
||||
final Contact contact;
|
||||
final ScrollController scrollController;
|
||||
|
||||
const _RoutingSheetBody({
|
||||
required this.contact,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_RoutingSheetBody> createState() => _RoutingSheetBodyState();
|
||||
}
|
||||
|
||||
class _RoutingSheetBodyState extends State<_RoutingSheetBody> {
|
||||
int _resolveContactIndex = -1;
|
||||
String? _syncStatus;
|
||||
|
||||
Contact _resolveContact(MeshCoreConnector connector) {
|
||||
if (_resolveContactIndex >= 0 &&
|
||||
_resolveContactIndex < connector.contacts.length &&
|
||||
connector.contacts[_resolveContactIndex].publicKeyHex ==
|
||||
widget.contact.publicKeyHex) {
|
||||
return connector.contacts[_resolveContactIndex];
|
||||
}
|
||||
_resolveContactIndex = connector.contacts.indexWhere(
|
||||
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
|
||||
);
|
||||
if (_resolveContactIndex == -1) {
|
||||
return widget.contact;
|
||||
}
|
||||
return connector.contacts[_resolveContactIndex];
|
||||
}
|
||||
|
||||
_RoutingMode _modeOf(Contact contact) {
|
||||
final override = contact.pathOverride;
|
||||
if (override == null) return _RoutingMode.auto;
|
||||
return override < 0 ? _RoutingMode.flood : _RoutingMode.manual;
|
||||
}
|
||||
|
||||
Future<void> _selectMode(
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
_RoutingMode mode,
|
||||
) async {
|
||||
switch (mode) {
|
||||
case _RoutingMode.auto:
|
||||
setState(() => _syncStatus = null);
|
||||
await connector.setPathOverride(contact, pathLen: null);
|
||||
case _RoutingMode.flood:
|
||||
setState(() => _syncStatus = null);
|
||||
await connector.setPathOverride(contact, pathLen: -1);
|
||||
case _RoutingMode.manual:
|
||||
await _editManualPath(connector, contact);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editManualPath(
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
) async {
|
||||
final override = contact.pathOverride;
|
||||
final initial = override != null && override > 0
|
||||
? (contact.pathOverrideBytes ?? Uint8List(0))
|
||||
: (contact.pathLength > 0 ? contact.path : Uint8List(0));
|
||||
final available = connector.allContacts
|
||||
.where((c) => c.publicKeyHex != contact.publicKeyHex)
|
||||
.toList();
|
||||
final result = await PathEditorSheet.show(
|
||||
context,
|
||||
availableContacts: available,
|
||||
initialPath: initial,
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
await connector.setPathOverride(
|
||||
contact,
|
||||
pathLen: result.length,
|
||||
pathBytes: result,
|
||||
);
|
||||
await _verifyPath(connector, contact, result);
|
||||
}
|
||||
|
||||
Future<void> _applyHistoryPath(
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
PathRecord record,
|
||||
) async {
|
||||
final bytes = Uint8List.fromList(record.pathBytes);
|
||||
await connector.setPathOverride(
|
||||
contact,
|
||||
pathLen: bytes.length,
|
||||
pathBytes: bytes,
|
||||
);
|
||||
await _verifyPath(connector, contact, bytes);
|
||||
}
|
||||
|
||||
Future<void> _verifyPath(
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
Uint8List bytes,
|
||||
) async {
|
||||
if (!mounted) return;
|
||||
if (!connector.isConnected) {
|
||||
setState(() => _syncStatus = context.l10n.chat_pathSavedLocally);
|
||||
return;
|
||||
}
|
||||
setState(() => _syncStatus = null);
|
||||
final verified = await connector.verifyContactPathOnDevice(contact, bytes);
|
||||
if (!mounted) return;
|
||||
setState(
|
||||
() => _syncStatus = verified
|
||||
? context.l10n.chat_pathDeviceConfirmed
|
||||
: context.l10n.chat_pathDeviceNotConfirmed,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _forgetPath(MeshCoreConnector connector, Contact contact) async {
|
||||
await connector.clearContactPath(contact);
|
||||
if (!mounted) return;
|
||||
setState(() => _syncStatus = context.l10n.chat_pathCleared);
|
||||
}
|
||||
|
||||
_PathQuality _qualityOf(PathRecord record, List<DirectRepeater> ranked) {
|
||||
if (record.pathBytes.isNotEmpty) {
|
||||
final first = record.pathBytes.first;
|
||||
for (var i = 0; i < ranked.length && i < 3; i++) {
|
||||
if (ranked[i].pubkeyFirstByte == first) {
|
||||
return switch (i) {
|
||||
0 => _PathQuality.strong,
|
||||
1 => _PathQuality.good,
|
||||
_ => _PathQuality.fair,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (record.successCount > 0) return _PathQuality.proven;
|
||||
if (record.wasFloodDiscovery) return _PathQuality.flood;
|
||||
return _PathQuality.untested;
|
||||
}
|
||||
|
||||
String _qualityLabel(BuildContext context, _PathQuality quality) {
|
||||
final l10n = context.l10n;
|
||||
return switch (quality) {
|
||||
_PathQuality.strong => l10n.routing_qualityStrong,
|
||||
_PathQuality.good => l10n.routing_qualityGood,
|
||||
_PathQuality.fair => l10n.routing_qualityFair,
|
||||
_PathQuality.proven => l10n.routing_qualityWorked,
|
||||
_PathQuality.flood => l10n.routing_qualityFlood,
|
||||
_PathQuality.untested => l10n.routing_qualityUntested,
|
||||
};
|
||||
}
|
||||
|
||||
IconData _qualityIcon(_PathQuality quality) {
|
||||
return switch (quality) {
|
||||
_PathQuality.strong => Icons.signal_cellular_alt,
|
||||
_PathQuality.good => Icons.signal_cellular_alt_2_bar,
|
||||
_PathQuality.fair => Icons.signal_cellular_alt_1_bar,
|
||||
_PathQuality.proven => Icons.check_circle_outline,
|
||||
_PathQuality.flood => Icons.waves,
|
||||
_PathQuality.untested => Icons.route,
|
||||
};
|
||||
}
|
||||
|
||||
String _relativeTime(BuildContext context, DateTime time) {
|
||||
final l10n = context.l10n;
|
||||
final diff = DateTime.now().difference(time);
|
||||
if (diff.inSeconds < 60) return l10n.time_justNow;
|
||||
if (diff.inMinutes < 60) return l10n.time_minutesAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) return l10n.time_hoursAgo(diff.inHours);
|
||||
return l10n.time_daysAgo(diff.inDays);
|
||||
}
|
||||
|
||||
String _modeHint(BuildContext context, _RoutingMode mode) {
|
||||
final l10n = context.l10n;
|
||||
return switch (mode) {
|
||||
_RoutingMode.auto => l10n.routing_modeAutoHint,
|
||||
_RoutingMode.flood => l10n.routing_modeFloodHint,
|
||||
_RoutingMode.manual => l10n.routing_modeManualHint,
|
||||
};
|
||||
}
|
||||
|
||||
String _routeText(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
_RoutingMode mode,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
switch (mode) {
|
||||
case _RoutingMode.flood:
|
||||
return l10n.routing_floodBroadcast;
|
||||
case _RoutingMode.manual:
|
||||
final bytes = contact.pathOverrideBytes ?? Uint8List(0);
|
||||
if (bytes.isEmpty) return l10n.routing_directNoHops;
|
||||
return PathHelper.resolvePathNames(bytes, connector.allContacts);
|
||||
case _RoutingMode.auto:
|
||||
if (contact.pathLength < 0) return l10n.routing_noPathYet;
|
||||
if (contact.pathLength == 0) return l10n.routing_directNoHops;
|
||||
if (contact.path.isEmpty) {
|
||||
return l10n.chat_hopsCount(contact.pathLength);
|
||||
}
|
||||
return PathHelper.resolvePathNames(contact.path, connector.allContacts);
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List _displayBytes(Contact contact, _RoutingMode mode) {
|
||||
return switch (mode) {
|
||||
_RoutingMode.flood => Uint8List(0),
|
||||
_RoutingMode.manual => contact.pathOverrideBytes ?? Uint8List(0),
|
||||
_RoutingMode.auto => contact.path,
|
||||
};
|
||||
}
|
||||
|
||||
void _openPathTrace(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
List<int> pathBytes,
|
||||
) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: context.l10n.contacts_repeaterPathTrace,
|
||||
path: Uint8List.fromList(pathBytes),
|
||||
flipPathAround: true,
|
||||
targetContact: contact,
|
||||
pathHashByteWidth: connector.pathHashByteWidth,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPathDetail(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
List<int> pathBytes,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final formattedPath = PathHelper.formatPathHex(pathBytes);
|
||||
final resolvedNames = PathHelper.resolvePathNames(
|
||||
pathBytes,
|
||||
connector.allContacts,
|
||||
);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(l10n.chat_fullPath),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(formattedPath),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
resolvedNames,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(dialogContext).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_openPathTrace(dialogContext, connector, contact, pathBytes),
|
||||
child: Text(l10n.contacts_pathTrace),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _currentRouteCard(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
_RoutingMode mode,
|
||||
({
|
||||
int successCount,
|
||||
int failureCount,
|
||||
int lastTripTimeMs,
|
||||
DateTime? lastUsed,
|
||||
})?
|
||||
floodStats,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final displayBytes = _displayBytes(contact, mode);
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
mode == _RoutingMode.flood ? Icons.waves : Icons.route,
|
||||
size: 18,
|
||||
color: scheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.routing_currentRoute,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_routeText(context, connector, contact, mode),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
if (mode == _RoutingMode.flood &&
|
||||
floodStats != null &&
|
||||
(floodStats.successCount > 0 || floodStats.failureCount > 0))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
_floodStatsLine(context, floodStats),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_syncStatus != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
_syncStatus!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.tertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (displayBytes.isNotEmpty)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.map_outlined, size: 18),
|
||||
label: Text(l10n.contacts_pathTrace),
|
||||
onPressed: () => _openPathTrace(
|
||||
context,
|
||||
connector,
|
||||
contact,
|
||||
displayBytes,
|
||||
),
|
||||
),
|
||||
if (mode == _RoutingMode.manual)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: Text(l10n.routing_editPath),
|
||||
onPressed: () => _editManualPath(connector, contact),
|
||||
),
|
||||
if (mode == _RoutingMode.auto && contact.pathLength >= 0)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.restart_alt, size: 18),
|
||||
label: Text(l10n.routing_forgetPath),
|
||||
onPressed: () => _forgetPath(connector, contact),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _floodStatsLine(
|
||||
BuildContext context,
|
||||
({
|
||||
int successCount,
|
||||
int failureCount,
|
||||
int lastTripTimeMs,
|
||||
DateTime? lastUsed,
|
||||
})
|
||||
stats,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final parts = <String>[
|
||||
l10n.routing_deliveryCounts(stats.successCount, stats.failureCount),
|
||||
if (stats.lastTripTimeMs > 0)
|
||||
'${(stats.lastTripTimeMs / 1000).toStringAsFixed(1)}s',
|
||||
if (stats.lastUsed != null)
|
||||
l10n.routing_lastWorked(_relativeTime(context, stats.lastUsed!)),
|
||||
];
|
||||
return parts.join(' • ');
|
||||
}
|
||||
|
||||
Widget _floodTile(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
_RoutingMode mode,
|
||||
({
|
||||
int successCount,
|
||||
int failureCount,
|
||||
int lastTripTimeMs,
|
||||
DateTime? lastUsed,
|
||||
})
|
||||
stats,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: scheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.waves, size: 18, color: scheme.onSurfaceVariant),
|
||||
),
|
||||
title: Text(l10n.routing_floodDelivery),
|
||||
subtitle: Text(
|
||||
_floodStatsLine(context, stats),
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
trailing: mode == _RoutingMode.flood
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
color: scheme.primary,
|
||||
semanticLabel: l10n.routing_inUse,
|
||||
)
|
||||
: null,
|
||||
onTap: mode == _RoutingMode.flood
|
||||
? null
|
||||
: () => _selectMode(connector, contact, _RoutingMode.flood),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pathRecordTile(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Contact contact,
|
||||
_RoutingMode mode,
|
||||
PathHistoryService pathService,
|
||||
PathRecord record,
|
||||
_PathQuality quality,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
final (Color bg, Color fg) = switch (quality) {
|
||||
_PathQuality.strong => (
|
||||
scheme.primaryContainer,
|
||||
scheme.onPrimaryContainer,
|
||||
),
|
||||
_PathQuality.good => (
|
||||
scheme.secondaryContainer,
|
||||
scheme.onSecondaryContainer,
|
||||
),
|
||||
_PathQuality.fair => (
|
||||
scheme.tertiaryContainer,
|
||||
scheme.onTertiaryContainer,
|
||||
),
|
||||
_PathQuality.proven => (
|
||||
scheme.primaryContainer,
|
||||
scheme.onPrimaryContainer,
|
||||
),
|
||||
_ => (scheme.surfaceContainerHighest, scheme.onSurfaceVariant),
|
||||
};
|
||||
|
||||
final hasBytes = record.pathBytes.isNotEmpty;
|
||||
final inUse =
|
||||
hasBytes &&
|
||||
((mode == _RoutingMode.manual &&
|
||||
listEquals(record.pathBytes, contact.pathOverrideBytes)) ||
|
||||
(mode == _RoutingMode.auto &&
|
||||
listEquals(record.pathBytes, contact.path)));
|
||||
|
||||
final title = hasBytes
|
||||
? PathHelper.resolvePathNames(record.pathBytes, connector.allContacts)
|
||||
: l10n.chat_hopsCount(record.hopCount);
|
||||
|
||||
final line1 =
|
||||
'${l10n.chat_hopsCount(record.hopCount)} • ${_qualityLabel(context, quality)}';
|
||||
final line2Parts = <String>[
|
||||
record.timestamp != null
|
||||
? l10n.routing_lastWorked(_relativeTime(context, record.timestamp!))
|
||||
: l10n.routing_neverWorked,
|
||||
if (record.tripTimeMs > 0)
|
||||
'${(record.tripTimeMs / 1000).toStringAsFixed(1)}s',
|
||||
l10n.routing_deliveryCounts(record.successCount, record.failureCount),
|
||||
];
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
enabled: hasBytes,
|
||||
leading: CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: bg,
|
||||
child: Icon(
|
||||
_qualityIcon(quality),
|
||||
size: 18,
|
||||
color: fg,
|
||||
semanticLabel: _qualityLabel(context, quality),
|
||||
),
|
||||
),
|
||||
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
'$line1\n${line2Parts.join(' • ')}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (inUse)
|
||||
Tooltip(
|
||||
message: l10n.routing_inUse,
|
||||
child: Icon(
|
||||
Icons.check_circle,
|
||||
color: scheme.primary,
|
||||
semanticLabel: l10n.routing_inUse,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
tooltip: l10n.chat_removePath,
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
onPressed: () => pathService.removePathRecord(
|
||||
contact.publicKeyHex,
|
||||
record.pathBytes,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: hasBytes && !inUse
|
||||
? () => _applyHistoryPath(connector, contact, record)
|
||||
: null,
|
||||
onLongPress: hasBytes
|
||||
? () =>
|
||||
_showPathDetail(context, connector, contact, record.pathBytes)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Consumer2<MeshCoreConnector, PathHistoryService>(
|
||||
builder: (context, connector, pathService, _) {
|
||||
final contact = _resolveContact(connector);
|
||||
final mode = _modeOf(contact);
|
||||
final floodStats = pathService.getFloodStats(contact.publicKeyHex);
|
||||
final hasFloodStats =
|
||||
floodStats != null &&
|
||||
(floodStats.successCount > 0 || floodStats.failureCount > 0);
|
||||
|
||||
final rankedRepeaters = List.of(connector.directRepeaters)
|
||||
..sort((a, b) => b.ranking.compareTo(a.ranking));
|
||||
final entries =
|
||||
pathService
|
||||
.getRecentPaths(contact.publicKeyHex)
|
||||
.map(
|
||||
(r) => (quality: _qualityOf(r, rankedRepeaters), record: r),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final byQuality = a.quality.index.compareTo(b.quality.index);
|
||||
if (byQuality != 0) return byQuality;
|
||||
final aTime =
|
||||
a.record.timestamp ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bTime =
|
||||
b.record.timestamp ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
return bTime.compareTo(aTime);
|
||||
});
|
||||
|
||||
return ListView(
|
||||
controller: widget.scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.routing_title, style: theme.textTheme.titleLarge),
|
||||
Text(
|
||||
contact.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SegmentedButton<_RoutingMode>(
|
||||
style: const ButtonStyle(
|
||||
minimumSize: WidgetStatePropertyAll(Size.fromHeight(44)),
|
||||
),
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: _RoutingMode.auto,
|
||||
icon: const Icon(Icons.auto_mode),
|
||||
label: Text(l10n.routing_modeAuto),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _RoutingMode.flood,
|
||||
icon: const Icon(Icons.waves),
|
||||
label: Text(l10n.routing_modeFlood),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _RoutingMode.manual,
|
||||
icon: const Icon(Icons.edit_road),
|
||||
label: Text(l10n.routing_modeManual),
|
||||
),
|
||||
],
|
||||
selected: {mode},
|
||||
onSelectionChanged: (selection) =>
|
||||
_selectMode(connector, contact, selection.first),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_modeHint(context, mode),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_currentRouteCard(context, connector, contact, mode, floodStats),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.routing_knownPaths, style: theme.textTheme.titleSmall),
|
||||
Text(
|
||||
l10n.routing_knownPathsHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (hasFloodStats)
|
||||
_floodTile(context, connector, contact, mode, floodStats),
|
||||
if (entries.isEmpty && !hasFloodStats)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
l10n.chat_noPathHistoryYet,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
...entries.map(
|
||||
(entry) => _pathRecordTile(
|
||||
context,
|
||||
connector,
|
||||
contact,
|
||||
mode,
|
||||
pathService,
|
||||
entry.record,
|
||||
entry.quality,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class SignalUi {
|
||||
final IconData icon;
|
||||
@@ -12,27 +13,27 @@ SignalUi signalUiForStrengthTier(int tier) {
|
||||
case 0:
|
||||
return const SignalUi(
|
||||
icon: Icons.signal_cellular_4_bar,
|
||||
color: Colors.green,
|
||||
color: MeshPalette.signal,
|
||||
);
|
||||
case 1:
|
||||
return const SignalUi(
|
||||
icon: Icons.signal_cellular_alt,
|
||||
color: Colors.lightGreen,
|
||||
color: MeshPalette.signalDim,
|
||||
);
|
||||
case 2:
|
||||
return const SignalUi(
|
||||
icon: Icons.signal_cellular_alt_2_bar,
|
||||
color: Colors.amber,
|
||||
color: MeshPalette.warn,
|
||||
);
|
||||
case 3:
|
||||
return const SignalUi(
|
||||
icon: Icons.signal_cellular_alt_1_bar,
|
||||
color: Colors.orange,
|
||||
color: MeshPalette.warnDim,
|
||||
);
|
||||
default:
|
||||
return const SignalUi(
|
||||
icon: Icons.signal_cellular_alt_1_bar,
|
||||
color: Colors.red,
|
||||
color: MeshPalette.alert,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
import 'mesh_ui.dart';
|
||||
import 'signal_ui.dart';
|
||||
|
||||
Contact? _getRepeaterPrefixMatchNearLocation(
|
||||
@@ -166,7 +168,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -218,10 +220,6 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final repeater = directBestRepeaters[index];
|
||||
final snrUi = snrUiFromSNR(
|
||||
repeater.snr,
|
||||
widget.connector.currentSf,
|
||||
);
|
||||
final allContacts = widget.connector.allContacts;
|
||||
|
||||
final selfLat = widget.connector.selfLatitude;
|
||||
@@ -242,22 +240,47 @@ class _SNRIndicatorState extends State<SNRIndicator> {
|
||||
);
|
||||
|
||||
final name = contact?.name;
|
||||
final hex = repeater.pubkeyFirstByte
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0');
|
||||
final snrColor = MeshTheme.snrColor(
|
||||
repeater.snr,
|
||||
blocked: false,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(snrUi.icon, color: snrUi.color),
|
||||
title: Text(
|
||||
name ??
|
||||
repeater.pubkeyFirstByte
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0'),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
AvatarCircle(
|
||||
name: name ?? hex,
|
||||
size: 36,
|
||||
color: snrColor,
|
||||
),
|
||||
subtitle: Text(
|
||||
'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}',
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name ?? hex,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
'${repeater.snr.toStringAsFixed(1)} dB • ${_formatLastUpdated(repeater.lastUpdated)}',
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
color: snrColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
|
||||
class TelemetryLocationMap extends StatefulWidget {
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final String label;
|
||||
final int contactType;
|
||||
final String contactPublicKeyHex;
|
||||
|
||||
const TelemetryLocationMap({
|
||||
super.key,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.label,
|
||||
required this.contactType,
|
||||
required this.contactPublicKeyHex,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TelemetryLocationMap> createState() => _TelemetryLocationMapState();
|
||||
}
|
||||
|
||||
class _TelemetryLocationMapState extends State<TelemetryLocationMap> {
|
||||
static const double _initialZoom = 14.0;
|
||||
static const double _minZoom = 2.0;
|
||||
static const double _maxZoom = 18.0;
|
||||
|
||||
final MapController _mapController = MapController();
|
||||
|
||||
LatLng get _position => LatLng(widget.latitude, widget.longitude);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TelemetryLocationMap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.latitude == widget.latitude &&
|
||||
oldWidget.longitude == widget.longitude) {
|
||||
return;
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_mapController.move(_position, _initialZoom);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final settingsService = context.watch<AppSettingsService>();
|
||||
final settings = settingsService.settings;
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final contacts = _filteredContacts(connector, settings);
|
||||
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.75;
|
||||
final squareHeight = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: maxHeight;
|
||||
// Prefer square sizing by width, but cap only the height so the map
|
||||
// remains usable on wide screens without growing past the viewport.
|
||||
final mapHeight = squareHeight > maxHeight
|
||||
? maxHeight
|
||||
: squareHeight;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: mapHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: _position,
|
||||
initialZoom: _initialZoom,
|
||||
minZoom: _minZoom,
|
||||
maxZoom: _maxZoom,
|
||||
interactionOptions: InteractionOptions(
|
||||
flags: ~InteractiveFlag.rotate,
|
||||
scrollWheelVelocity: isDesktop ? 0.012 : 0.005,
|
||||
cursorKeyboardRotationOptions:
|
||||
CursorKeyboardRotationOptions.disabled(),
|
||||
keyboardOptions: isDesktop
|
||||
? const KeyboardOptions(
|
||||
enableArrowKeysPanning: true,
|
||||
enableWASDPanning: true,
|
||||
enableRFZooming: true,
|
||||
)
|
||||
: const KeyboardOptions.disabled(),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
tileCache.buildTileLayer(context),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
...contacts.map(_buildContactMarker),
|
||||
_buildTelemetryMarker(),
|
||||
_buildLabelMarker(_position, widget.label),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: _MapButton(
|
||||
icon: Icons.filter_list,
|
||||
tooltip: context.l10n.map_filterNodes,
|
||||
onPressed: () =>
|
||||
_showFilterDialog(context, settingsService),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
top: 8,
|
||||
child: Column(
|
||||
children: [
|
||||
_MapButton(
|
||||
icon: Icons.add,
|
||||
tooltip: context.l10n.map_zoomIn,
|
||||
onPressed: () => _zoomBy(1),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_MapButton(
|
||||
icon: Icons.remove,
|
||||
tooltip: context.l10n.map_zoomOut,
|
||||
onPressed: () => _zoomBy(-1),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_MapButton(
|
||||
icon: Icons.my_location,
|
||||
tooltip: context.l10n.map_centerMap,
|
||||
onPressed: () =>
|
||||
_mapController.move(_position, _initialZoom),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Contact> _filteredContacts(
|
||||
MeshCoreConnector connector,
|
||||
AppSettings settings,
|
||||
) {
|
||||
final contacts = settings.mapShowDiscoveryContacts
|
||||
? connector.allContacts
|
||||
: connector.allContacts.where((contact) => contact.isActive).toList();
|
||||
|
||||
return contacts.where((contact) {
|
||||
if (!contact.hasLocation) return false;
|
||||
if (contact.publicKeyHex == widget.contactPublicKeyHex) return false;
|
||||
if (contact.type == advTypeChat) return settings.mapShowChatNodes;
|
||||
if (contact.type == advTypeRepeater) return settings.mapShowRepeaters;
|
||||
return settings.mapShowOtherNodes;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Marker _buildTelemetryMarker() {
|
||||
return Marker(
|
||||
point: _position,
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: IgnorePointer(
|
||||
child: _MarkerBubble(
|
||||
color: Colors.red,
|
||||
icon: _getNodeIcon(widget.contactType),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Marker _buildContactMarker(Contact contact) {
|
||||
return Marker(
|
||||
point: LatLng(contact.latitude!, contact.longitude!),
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: IgnorePointer(
|
||||
child: _MarkerBubble(
|
||||
color: _getNodeColor(contact.type),
|
||||
icon: _getNodeIcon(contact.type),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Marker _buildLabelMarker(LatLng point, String label) {
|
||||
return Marker(
|
||||
point: point,
|
||||
width: 140,
|
||||
height: 24,
|
||||
alignment: Alignment.topCenter,
|
||||
child: IgnorePointer(
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, -24),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _zoomBy(double delta) {
|
||||
final camera = _mapController.camera;
|
||||
final nextZoom = (camera.zoom + delta).clamp(_minZoom, _maxZoom).toDouble();
|
||||
_mapController.move(camera.center, nextZoom);
|
||||
}
|
||||
|
||||
bool _isDesktopPlatform(TargetPlatform platform) {
|
||||
return platform == TargetPlatform.linux ||
|
||||
platform == TargetPlatform.windows ||
|
||||
platform == TargetPlatform.macOS;
|
||||
}
|
||||
|
||||
Color _getNodeColor(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Colors.blue;
|
||||
case advTypeRepeater:
|
||||
return Colors.green;
|
||||
case advTypeRoom:
|
||||
return Colors.purple;
|
||||
case advTypeSensor:
|
||||
return Colors.orange;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getNodeIcon(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Icons.person;
|
||||
case advTypeRepeater:
|
||||
return Icons.router;
|
||||
case advTypeRoom:
|
||||
return Icons.meeting_room;
|
||||
case advTypeSensor:
|
||||
return Icons.sensors;
|
||||
default:
|
||||
return Icons.device_unknown;
|
||||
}
|
||||
}
|
||||
|
||||
void _showFilterDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(context.l10n.map_filterNodes),
|
||||
content: SingleChildScrollView(
|
||||
child: Consumer<AppSettingsService>(
|
||||
builder: (consumerContext, service, child) {
|
||||
final settings = service.settings;
|
||||
// Reuse the global map filters so the telemetry preview and the
|
||||
// main map stay consistent without another settings model.
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
title: Text(context.l10n.map_chatNodes),
|
||||
value: settings.mapShowChatNodes,
|
||||
onChanged: (value) {
|
||||
service.setMapShowChatNodes(value ?? true);
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(context.l10n.map_repeaters),
|
||||
value: settings.mapShowRepeaters,
|
||||
onChanged: (value) {
|
||||
service.setMapShowRepeaters(value ?? true);
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(context.l10n.map_otherNodes),
|
||||
value: settings.mapShowOtherNodes,
|
||||
onChanged: (value) {
|
||||
service.setMapShowOtherNodes(value ?? true);
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(context.l10n.map_showDiscoveryContacts),
|
||||
value: settings.mapShowDiscoveryContacts,
|
||||
onChanged: (value) {
|
||||
service.setMapShowDiscoveryContacts(value ?? true);
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MarkerBubble extends StatelessWidget {
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
final double size;
|
||||
|
||||
const _MarkerBubble({
|
||||
required this.color,
|
||||
required this.icon,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, color: Colors.white, size: size),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _MapButton({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
elevation: 3,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: IconButton(
|
||||
icon: Icon(icon),
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
constraints: const BoxConstraints.tightFor(width: 40, height: 40),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class UnreadBadge extends StatelessWidget {
|
||||
final int count;
|
||||
|
||||
@@ -9,17 +11,18 @@ class UnreadBadge extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final display = count > 9999 ? '9999+' : count.toString();
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.redAccent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: MeshPalette.blue.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||
border: Border.all(color: MeshPalette.blue.withValues(alpha: 0.45)),
|
||||
),
|
||||
child: Text(
|
||||
display,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: MeshPalette.blue,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import '../theme/mesh_theme.dart';
|
||||
|
||||
class UnreadDivider extends StatelessWidget {
|
||||
const UnreadDivider({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).colorScheme.primary;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final color = scheme.primary;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Divider(color: color)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
Expanded(
|
||||
child: Container(height: 1, color: color.withValues(alpha: 0.25)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
||||
border: Border.all(color: color.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.chat_newMessages,
|
||||
style: TextStyle(
|
||||
style: MeshTheme.mono(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: color)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Container(height: 1, color: color.withValues(alpha: 0.25)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user