Files
meshcore-open/lib/widgets/path_editor_sheet.dart
T
zjs81 26fdf74d69 Refactor UI code for better readability and consistency
- Improved formatting of ListTile icons and text styles in settings_screen.dart, telemetry_screen.dart, usb_screen.dart, gif_picker.dart, path_editor_sheet.dart, repeater_login_dialog.dart, and room_login_dialog.dart for better readability.
- Consolidated TextStyle definitions into single lines where applicable.
- Updated notification_service.dart to enhance readability of notification ID assignment.
- Simplified function signatures in routing_sheet.dart for clarity.
- Cleaned up test assertions in usb_flow_test.dart for conciseness.
- Removed unused translations in untranslated.json to streamline localization files.
2026-06-11 00:28:13 -07:00

377 lines
11 KiB
Dart

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),
),
),
],
),
),
),
],
);
}
}