Merge branch 'dev' into update-pacox-multibyte

This commit is contained in:
HDDen
2026-06-12 13:39:09 +03:00
95 changed files with 14166 additions and 6756 deletions
+5 -3
View File
@@ -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 {
),
),
),
),
],
);
},
+16 -6
View File
@@ -6,9 +6,15 @@ import 'signal_ui.dart';
/// A reusable tile widget for displaying a MeshCore device in a list
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) {
@@ -19,16 +25,20 @@ class DeviceTile extends StatelessWidget {
: scanResult.advertisementData.advName;
return ListTile(
enabled: onTap != null || isConnecting,
leading: _buildSignalIcon(rssi),
title: Text(
name.isNotEmpty ? name : context.l10n.common_unknownDevice,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(device.remoteId.toString()),
trailing: ElevatedButton(
onPressed: onTap,
child: Text(context.l10n.common_connect),
),
trailing: isConnecting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
onTap: onTap,
);
}
+7 -3
View File
@@ -17,18 +17,22 @@ class EmptyState extends StatelessWidget {
@override
Widget build(BuildContext context) {
final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Colors.grey[400]),
Icon(icon, size: 64, color: onSurfaceVariant.withValues(alpha: 0.6)),
const SizedBox(height: 16),
Text(title, style: TextStyle(fontSize: 16, color: Colors.grey[600])),
Text(title, style: TextStyle(fontSize: 16, color: onSurfaceVariant)),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
style: TextStyle(
fontSize: 14,
color: onSurfaceVariant.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
),
],
+22 -5
View File
@@ -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,
),
),
],
),
+135 -16
View File
@@ -1,36 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class MessageStatusIcon extends StatelessWidget {
import '../l10n/l10n.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;
final Color color = delivered ? colorScheme.tertiary : 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,
),
),
);
}),
);
},
),
);
}
}
+412
View File
@@ -0,0 +1,412 @@
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;
final int pathHashByteWidth;
const PathEditorSheet({
super.key,
required this.availableContacts,
this.initialPath = const [],
this.pathHashByteWidth = pathHashSize,
});
static Future<Uint8List?> show(
BuildContext context, {
required List<Contact> availableContacts,
List<int> initialPath = const [],
int pathHashByteWidth = pathHashSize,
}) {
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,
pathHashByteWidth: pathHashByteWidth,
),
),
),
);
}
@override
State<PathEditorSheet> createState() => _PathEditorSheetState();
}
class _Hop {
final int id;
final Uint8List bytes;
const _Hop(this.id, this.bytes);
}
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 hop in PathHelper.splitPathBytes(
widget.initialPath,
widget.pathHashByteWidth,
)) {
_hops.add(_Hop(_nextHopId++, hop));
}
_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 = _hops.map((h) => PathHelper.formatHopHex(h.bytes)).join(',');
_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 width = widget.pathHashByteWidth.clamp(1, pubKeySize).toInt();
final expectedChars = width * 2;
final invalid = tokens
.where(
(t) =>
t.length != expectedChars ||
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) {
final bytes = <int>[];
for (var i = 0; i < t.length; i += 2) {
bytes.add(int.parse(t.substring(i, i + 2), radix: 16));
}
return _Hop(_nextHopId++, Uint8List.fromList(bytes));
}),
);
});
}
void _addHop(Contact contact) {
if (_hops.length >= _maxHops) return;
final width = widget.pathHashByteWidth.clamp(1, contact.publicKey.length).toInt();
setState(() {
_hops.add(
_Hop(_nextHopId++, Uint8List.fromList(contact.publicKey.sublist(0, width))),
);
_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() {
final bytes = <int>[];
for (final hop in _hops) {
bytes.addAll(hop.bytes);
}
Navigator.pop(
context,
Uint8List.fromList(bytes),
);
}
Widget _hopTile(BuildContext context, int index) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final hop = _hops[index];
final hex = PathHelper.formatHopHex(hop.bytes);
final matches = widget.availableContacts.where((contact) {
if (contact.publicKey.length < hop.bytes.length) return false;
return contact.publicKey
.sublist(0, hop.bytes.length)
.asMap()
.entries
.every((entry) => entry.value == hop.bytes[entry.key]);
}).toList();
final name = matches.isEmpty
? null
: matches.length == 1
? matches.first.name
: matches.map((c) => c.name).join(' | ');
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.formatHopHex(contact.publicKey.sublist(0, widget.pathHashByteWidth.clamp(1, contact.publicKey.length).toInt()))}',
),
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),
),
),
],
),
),
),
],
);
}
}
-524
View File
@@ -1,524 +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.splitPathBytes(
pathBytes,
connector.pathHashByteWidth,
).map(PathHelper.formatHopHex).join(',');
final resolvedNames = PathHelper.resolvePathNames(
pathBytes,
allContacts,
connector.pathHashByteWidth,
);
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,
pathHashByteWidth: connector.pathHashByteWidth,
);
if (result != null && context.mounted) {
final hopsCount = result.length ~/ connector.pathHashByteWidth;
await connector.setPathOverride(
currentContact,
pathLen: hopsCount,
pathBytes: result,
);
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(l10n.chat_hopsCount(hopsCount)),
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 &&
directRepeater.matchesPathStart(
path.pathBytes,
connector.pathHashByteWidth,
);
final isSecondDirectRepeater =
secondDirectRepeater != null &&
secondDirectRepeater.matchesPathStart(
path.pathBytes,
connector.pathHashByteWidth,
);
final isThirdDirectRepeater =
thirdDirectRepeater != null &&
thirdDirectRepeater.matchesPathStart(
path.pathBytes,
connector.pathHashByteWidth,
);
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,
);
await connector.setPathOverride(
currentContact,
pathLen: path.hopCount,
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),
),
],
);
},
);
}
}
-359
View File
@@ -1,359 +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;
final int pathHashByteWidth;
const PathSelectionDialog({
super.key,
required this.availableContacts,
required this.title,
this.initialPath,
this.currentPathLabel,
this.onRefresh,
this.pathHashByteWidth = 1,
});
@override
State<PathSelectionDialog> createState() => _PathSelectionDialogState();
static Future<Uint8List?> show(
BuildContext context, {
required List<Contact> availableContacts,
String? title,
String? initialPath,
String? currentPathLabel,
VoidCallback? onRefresh,
int pathHashByteWidth = 1,
}) {
return showDialog<Uint8List?>(
context: context,
builder: (context) => PathSelectionDialog(
availableContacts: availableContacts,
title: title ?? context.l10n.path_enterCustomPath,
initialPath: initialPath,
currentPathLabel: currentPathLabel,
onRefresh: onRefresh,
pathHashByteWidth: pathHashByteWidth,
),
);
}
}
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 width = widget.pathHashByteWidth.clamp(1, 4);
final hexCharsPerHop = width * 2;
final pathParts = _selectedContacts
.map((contact) {
if (contact.publicKeyHex.length >= hexCharsPerHop) {
return contact.publicKeyHex.substring(0, hexCharsPerHop);
}
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>[];
final pathHashByteWidth = widget.pathHashByteWidth.clamp(1, 4).toInt();
final hexCharsPerHop = pathHashByteWidth * 2;
for (final id in pathIds) {
if (id.length < hexCharsPerHop) {
invalidPrefixes.add(id);
continue;
}
final prefix = id.substring(0, hexCharsPerHop);
try {
for (int i = 0; i < prefix.length; i += 2) {
pathBytesList.add(int.parse(prefix.substring(i, i + 2), radix: 16));
}
} 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;
}
final hopCount = pathBytesList.length ~/ pathHashByteWidth;
final maxHopCountByBytes = maxPathSize ~/ pathHashByteWidth;
final maxHopCount = maxHopCountByBytes < 0x3F ? maxHopCountByBytes : 0x3F;
// path_len stores hop count in 6 bits and the path buffer is byte-limited.
if (hopCount > maxHopCount || pathBytesList.length > maxPathSize) {
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: 188, // 63 one-byte hops * 2 chars + 62 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, widget.pathHashByteWidth.clamp(1, 4) * 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),
),
],
);
}
}
+7 -20
View File
@@ -67,10 +67,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 +80,12 @@ class QuickSwitchBar extends StatelessWidget {
),
NavigationDestination(
icon: _buildIconWithBadge(
context,
const Icon(Icons.tag),
channelsUnreadCount,
),
selectedIcon: _buildIconWithBadge(
context,
const Icon(Icons.tag),
channelsUnreadCount,
),
@@ -101,26 +105,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);
}
}
+9 -5
View File
@@ -59,11 +59,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,
),
),
);
}
+11 -11
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import '../utils/platform_info.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
@@ -11,7 +10,7 @@ import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../utils/app_logger.dart';
import 'path_management_dialog.dart';
import 'routing_sheet.dart';
class RepeaterLoginDialog extends StatefulWidget {
final Contact repeater;
@@ -276,7 +275,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
return AlertDialog(
title: Row(
children: [
const Icon(Icons.cell_tower, color: Colors.orange),
Icon(Icons.cell_tower, color: Theme.of(context).colorScheme.tertiary),
const SizedBox(width: 8),
Expanded(
child: Column(
@@ -288,7 +287,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
@@ -365,9 +364,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
}
},
onSubmitted: (_) => _handleLogin(),
autofocus:
!PlatformInfo.isMobile &&
_passwordController.text.isEmpty,
autofocus: _passwordController.text.isEmpty,
),
const SizedBox(height: 12),
CheckboxListTile(
@@ -469,14 +466,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: Theme.of(context).colorScheme.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 +497,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: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(width: 12),
+11 -8
View File
@@ -12,7 +12,7 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.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 +181,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,
);
}
}
@@ -232,7 +232,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
return AlertDialog(
title: Row(
children: [
const Icon(Icons.group, color: Colors.purple),
Icon(Icons.group, color: Theme.of(context).colorScheme.secondary),
const SizedBox(width: 8),
Expanded(
child: Column(
@@ -244,7 +244,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
color: Colors.grey[600],
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
@@ -395,14 +395,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: Theme.of(context).colorScheme.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 +426,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: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(width: 12),
+751
View File
@@ -0,0 +1,751 @@
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,
pathHashByteWidth: connector.pathHashByteWidth,
);
if (result == null || !mounted) return;
final hopCount = result.length ~/ connector.pathHashByteWidth;
await connector.setPathOverride(
contact,
pathLen: hopCount,
pathBytes: result,
);
await _verifyPath(connector, contact, result);
}
Future<void> _applyHistoryPath(
MeshCoreConnector connector,
Contact contact,
PathRecord record,
) async {
final bytes = Uint8List.fromList(record.pathBytes);
final hopCount = bytes.length ~/ connector.pathHashByteWidth;
await connector.setPathOverride(
contact,
pathLen: hopCount,
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(
MeshCoreConnector connector,
PathRecord record,
List<DirectRepeater> ranked,
) {
if (record.pathBytes.isNotEmpty) {
for (var i = 0; i < ranked.length && i < 3; i++) {
if (ranked[i].matchesPathStart(
record.pathBytes,
connector.pathHashByteWidth,
)) {
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,
connector.pathHashByteWidth,
);
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,
connector.pathHashByteWidth,
);
}
}
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.splitPathBytes(
pathBytes,
connector.pathHashByteWidth,
).map(PathHelper.formatHopHex).join(',');
final resolvedNames = PathHelper.resolvePathNames(
pathBytes,
connector.allContacts,
connector.pathHashByteWidth,
);
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,
connector.pathHashByteWidth,
)
: 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(connector, 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 -1
View File
@@ -180,7 +180,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,
+428
View File
@@ -0,0 +1,428 @@
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: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
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,
),
);
}
}