mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix voice quality regression
This commit is contained in:
@@ -8,7 +8,9 @@ import '../../models/contact.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../services/contact_route_resolver.dart';
|
||||
import '../../services/path_history_service.dart';
|
||||
import '../../services/route_hash_preferences.dart';
|
||||
import '../../models/path_history.dart';
|
||||
|
||||
class ContactRouteDialogResult {
|
||||
final ParsedContactRoute? route;
|
||||
@@ -75,11 +77,14 @@ class ContactRouteDialog extends StatefulWidget {
|
||||
|
||||
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
late final TextEditingController _controller;
|
||||
final PathHistoryService _pathHistoryService = PathHistoryService();
|
||||
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
||||
ParsedContactRoute? _parsedRoute;
|
||||
String? _errorText;
|
||||
bool _showRoutingInfo = false;
|
||||
List<Contact> _selectedMapHops = const [];
|
||||
ContactPathHistory? _pathHistory;
|
||||
_RouteEntryMode _entryMode = _RouteEntryMode.map;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -88,7 +93,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
text: widget.contact.routeCanonicalText,
|
||||
);
|
||||
_controller.addListener(_reparse);
|
||||
_entryMode = widget.contact.routeCanonicalText.isNotEmpty
|
||||
? _RouteEntryMode.manual
|
||||
: _RouteEntryMode.map;
|
||||
_loadHashSizePreference();
|
||||
_loadPathHistory();
|
||||
_reparse();
|
||||
}
|
||||
|
||||
@@ -106,6 +115,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
setState(() {
|
||||
_parsedRoute = null;
|
||||
_errorText = null;
|
||||
_selectedMapHops = const [];
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -115,9 +125,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
input,
|
||||
expectedHashSize: _selectedHashSize,
|
||||
);
|
||||
final selectedMapHops = _mapSelectionForText(input);
|
||||
setState(() {
|
||||
_parsedRoute = parsed;
|
||||
_errorText = null;
|
||||
_selectedMapHops = selectedMapHops;
|
||||
});
|
||||
} on ContactRouteFormatException catch (error) {
|
||||
setState(() {
|
||||
@@ -135,8 +147,8 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
.toList()
|
||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
|
||||
void _syncMapSelectionFromController() {
|
||||
final tokens = _controller.text
|
||||
List<Contact> _mapSelectionForText(String text) {
|
||||
final tokens = text
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((token) => token.trim().toUpperCase())
|
||||
@@ -154,7 +166,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
selected.add(match);
|
||||
}
|
||||
}
|
||||
_selectedMapHops = selected;
|
||||
return selected;
|
||||
}
|
||||
|
||||
Future<void> _loadHashSizePreference() async {
|
||||
@@ -164,7 +176,16 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
_selectedHashSize = hashSize;
|
||||
});
|
||||
_reparse();
|
||||
_syncMapSelectionFromController();
|
||||
}
|
||||
|
||||
Future<void> _loadPathHistory() async {
|
||||
await _pathHistoryService.initialize();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pathHistory = _pathHistoryService.historyFor(
|
||||
widget.contact.publicKeyHex,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String _tokenFor(Contact contact, int hashSize) {
|
||||
@@ -210,6 +231,23 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
TextPosition(offset: _controller.text.length),
|
||||
);
|
||||
_errorText = null;
|
||||
_entryMode = _RouteEntryMode.map;
|
||||
});
|
||||
_reparse();
|
||||
}
|
||||
|
||||
void _applyHistoryRecord(PathRecord record) {
|
||||
final canonicalText = _canonicalRouteFromBytes(
|
||||
record.pathBytes,
|
||||
hashSize: record.hashSize,
|
||||
);
|
||||
setState(() {
|
||||
_controller.text = canonicalText;
|
||||
_controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _controller.text.length),
|
||||
);
|
||||
_errorText = null;
|
||||
_entryMode = _RouteEntryMode.manual;
|
||||
});
|
||||
_reparse();
|
||||
}
|
||||
@@ -297,6 +335,295 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
_applyResolvedPlan(plan);
|
||||
}
|
||||
|
||||
String _canonicalRouteFromBytes(
|
||||
List<int> pathBytes, {
|
||||
required int hashSize,
|
||||
}) {
|
||||
final hops = <String>[];
|
||||
for (var i = 0; i < pathBytes.length; i += hashSize) {
|
||||
final hop = pathBytes.sublist(i, i + hashSize);
|
||||
hops.add(
|
||||
hop
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase(),
|
||||
);
|
||||
}
|
||||
return hops.join(',');
|
||||
}
|
||||
|
||||
String _historySubtitle(PathRecord record) {
|
||||
final attempts = record.successCount + record.failureCount;
|
||||
final lastSeen = MaterialLocalizations.of(
|
||||
context,
|
||||
).formatShortDate(record.lastUsedAt);
|
||||
final successRate = attempts == 0
|
||||
? 'No send stats yet'
|
||||
: '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}';
|
||||
final latency = record.lastRoundTripTimeMs > 0
|
||||
? ' • ${record.lastRoundTripTimeMs} ms'
|
||||
: '';
|
||||
return '$successRate • Last used $lastSeen$latency';
|
||||
}
|
||||
|
||||
Widget _buildPreviewSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_parsedRoute == null
|
||||
? 'Preview: enter or pick a route to validate it.'
|
||||
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (_parsedRoute != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
_parsedRoute!.canonicalText,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBuilderTab(
|
||||
BuildContext context, {
|
||||
required List<Contact> routeCandidates,
|
||||
required List<LatLng> mapPoints,
|
||||
required List<LatLng> routePoints,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SegmentedButton<_RouteEntryMode>(
|
||||
segments: const [
|
||||
ButtonSegment<_RouteEntryMode>(
|
||||
value: _RouteEntryMode.map,
|
||||
icon: Icon(Icons.map_outlined),
|
||||
label: Text('Map'),
|
||||
),
|
||||
ButtonSegment<_RouteEntryMode>(
|
||||
value: _RouteEntryMode.manual,
|
||||
icon: Icon(Icons.tune),
|
||||
label: Text('Manual'),
|
||||
),
|
||||
],
|
||||
selected: {_entryMode},
|
||||
onSelectionChanged: (selection) {
|
||||
setState(() {
|
||||
_entryMode = selection.first;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_entryMode == _RouteEntryMode.manual) ...[
|
||||
TextField(
|
||||
controller: _controller,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Route',
|
||||
hintText: _selectedHashSize == 1
|
||||
? 'AA,BB,CC'
|
||||
: _selectedHashSize == 2
|
||||
? 'AABB,CCDD'
|
||||
: 'AABBCC,DDEEFF',
|
||||
helperText:
|
||||
'Enter comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
||||
errorText: _errorText,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildPreviewSection(),
|
||||
] else ...[
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _resolvePathAutomatically,
|
||||
icon: const Icon(Icons.auto_fix_high),
|
||||
label: const Text('Resolve Path'),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Text(
|
||||
'Tap repeaters on the map to build the path, then review the generated route below.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
child: mapPoints.length < 2
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Map path builder needs your advertised location, the contact location, and visible repeater locations.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
)
|
||||
: flutter_map.FlutterMap(
|
||||
options: flutter_map.MapOptions(
|
||||
initialCameraFit: flutter_map.CameraFit.bounds(
|
||||
bounds: flutter_map.LatLngBounds.fromPoints(
|
||||
mapPoints,
|
||||
),
|
||||
padding: const EdgeInsets.all(32),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
flutter_map.TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.meshcore.sar',
|
||||
),
|
||||
if (routePoints.length >= 2)
|
||||
flutter_map.PolylineLayer(
|
||||
polylines: [
|
||||
flutter_map.Polyline(
|
||||
points: routePoints,
|
||||
strokeWidth: 4,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
flutter_map.MarkerLayer(
|
||||
markers: [
|
||||
...routeCandidates.map((candidate) {
|
||||
final isSelected = _selectedMapHops.any(
|
||||
(item) =>
|
||||
item.publicKeyHex ==
|
||||
candidate.publicKeyHex,
|
||||
);
|
||||
return flutter_map.Marker(
|
||||
point: LatLng(
|
||||
candidate.displayLocation!.latitude,
|
||||
candidate.displayLocation!.longitude,
|
||||
),
|
||||
width: 64,
|
||||
height: 70,
|
||||
child: GestureDetector(
|
||||
onTap: () => _toggleHop(candidate),
|
||||
child: _RouteMarkerDot(
|
||||
label: _tokenFor(
|
||||
candidate,
|
||||
_selectedHashSize,
|
||||
),
|
||||
color: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary
|
||||
: Colors.blueGrey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_selectedMapHops.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _selectedMapHops.map((contact) {
|
||||
return InputChip(
|
||||
label: Text(contact.displayName),
|
||||
onDeleted: () => _toggleHop(contact),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
readOnly: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Generated route',
|
||||
helperText: 'Switch to Manual if you want to edit the hop list.',
|
||||
errorText: _errorText,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildPreviewSection(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryTab() {
|
||||
final records = List<PathRecord>.from(_pathHistory?.directPaths ?? const [])
|
||||
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
|
||||
if (records.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No historical paths for this contact yet.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: records.length,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final record = records[index];
|
||||
final canonicalText = _canonicalRouteFromBytes(
|
||||
record.pathBytes,
|
||||
hashSize: record.hashSize,
|
||||
);
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
title: Text(
|
||||
canonicalText,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(_historySubtitle(record)),
|
||||
),
|
||||
trailing: FilledButton.tonal(
|
||||
onPressed: () => _applyHistoryRecord(record),
|
||||
child: const Text('Use'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appProvider = context.watch<AppProvider>();
|
||||
@@ -339,238 +666,107 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
),
|
||||
];
|
||||
|
||||
return FractionallySizedBox(
|
||||
heightFactor: 0.85,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Set Route for ${widget.contact.displayName}',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: FractionallySizedBox(
|
||||
heightFactor: 0.85,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Set Route for ${widget.contact.displayName}',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Choose how to build the route, or reuse one from history.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Build'),
|
||||
Tab(text: 'History'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _controller,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Route',
|
||||
hintText: _selectedHashSize == 1
|
||||
? 'AA,BB,CC'
|
||||
: _selectedHashSize == 2
|
||||
? 'AABB,CCDD'
|
||||
: 'AABBCC,DDEEFF',
|
||||
helperText:
|
||||
'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
||||
errorText: _errorText,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_parsedRoute == null
|
||||
? 'Preview: enter a route to validate it.'
|
||||
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (_parsedRoute != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
_parsedRoute!.canonicalText,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _resolvePathAutomatically,
|
||||
icon: const Icon(Icons.auto_fix_high),
|
||||
label: const Text('Resolve Path'),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 260),
|
||||
child: Text(
|
||||
'Tap repeaters on the map to build the path.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildBuilderTab(
|
||||
context,
|
||||
routeCandidates: routeCandidates,
|
||||
mapPoints: mapPoints,
|
||||
routePoints: routePoints,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 260,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_AutomationRoutingInfo(
|
||||
isExpanded: _showRoutingInfo,
|
||||
onToggle: () {
|
||||
setState(() {
|
||||
_showRoutingInfo = !_showRoutingInfo;
|
||||
});
|
||||
},
|
||||
autoRouteRotationEnabled:
|
||||
appProvider.autoRouteRotationEnabled,
|
||||
nearestRelayFallbackEnabled:
|
||||
appProvider.nearestRelayFallbackEnabled,
|
||||
clearPathOnMaxRetry:
|
||||
appProvider.clearPathOnMaxRetry,
|
||||
),
|
||||
child: mapPoints.length < 2
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Map path builder needs your advertised location, the contact location, and visible repeater locations.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
)
|
||||
: flutter_map.FlutterMap(
|
||||
options: flutter_map.MapOptions(
|
||||
initialCameraFit:
|
||||
flutter_map.CameraFit.bounds(
|
||||
bounds:
|
||||
flutter_map
|
||||
.LatLngBounds.fromPoints(
|
||||
mapPoints,
|
||||
),
|
||||
padding: const EdgeInsets.all(32),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
flutter_map.TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.meshcore.sar',
|
||||
),
|
||||
if (routePoints.length >= 2)
|
||||
flutter_map.PolylineLayer(
|
||||
polylines: [
|
||||
flutter_map.Polyline(
|
||||
points: routePoints,
|
||||
strokeWidth: 4,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
flutter_map.MarkerLayer(
|
||||
markers: [
|
||||
...routeCandidates.map((candidate) {
|
||||
final isSelected = _selectedMapHops
|
||||
.any(
|
||||
(item) =>
|
||||
item.publicKeyHex ==
|
||||
candidate.publicKeyHex,
|
||||
);
|
||||
return flutter_map.Marker(
|
||||
point: LatLng(
|
||||
candidate
|
||||
.displayLocation!
|
||||
.latitude,
|
||||
candidate
|
||||
.displayLocation!
|
||||
.longitude,
|
||||
),
|
||||
width: 64,
|
||||
height: 70,
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
_toggleHop(candidate),
|
||||
child: _RouteMarkerDot(
|
||||
label: _tokenFor(
|
||||
candidate,
|
||||
_selectedHashSize,
|
||||
),
|
||||
color: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary
|
||||
: Colors.blueGrey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_selectedMapHops.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _selectedMapHops.map((contact) {
|
||||
return InputChip(
|
||||
label: Text(contact.displayName),
|
||||
onDeleted: () => _toggleHop(contact),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_AutomationRoutingInfo(
|
||||
isExpanded: _showRoutingInfo,
|
||||
onToggle: () {
|
||||
setState(() {
|
||||
_showRoutingInfo = !_showRoutingInfo;
|
||||
});
|
||||
},
|
||||
autoRouteRotationEnabled:
|
||||
appProvider.autoRouteRotationEnabled,
|
||||
nearestRelayFallbackEnabled:
|
||||
appProvider.nearestRelayFallbackEnabled,
|
||||
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SingleChildScrollView(child: _buildHistoryTab()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
OverflowBar(
|
||||
alignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: 8,
|
||||
overflowSpacing: 8,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
if (widget.contact.routeHasPath)
|
||||
OverflowBar(
|
||||
alignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: 8,
|
||||
overflowSpacing: 8,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).pop(const ContactRouteDialogResult.clear()),
|
||||
child: const Text('Clear Route'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _parsedRoute == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(
|
||||
ContactRouteDialogResult.setWithFallback(
|
||||
_parsedRoute!,
|
||||
inferredFallbackLocation:
|
||||
_buildSyntheticFallbackLocation(),
|
||||
if (widget.contact.routeHasPath)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).pop(const ContactRouteDialogResult.clear()),
|
||||
child: const Text('Clear Route'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _parsedRoute == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(
|
||||
ContactRouteDialogResult.setWithFallback(
|
||||
_parsedRoute!,
|
||||
inferredFallbackLocation:
|
||||
_buildSyntheticFallbackLocation(),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Set Route'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: const Text('Set Route'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _RouteEntryMode { map, manual }
|
||||
|
||||
class _RouteMarkerDot extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../../l10n/app_localizations.dart';
|
||||
|
||||
class ContactTile extends StatelessWidget {
|
||||
final Contact contact;
|
||||
final String? groupLabel;
|
||||
final Position? currentPosition;
|
||||
final double Function(double, double, double, double)? calculateDistance;
|
||||
final String Function(double)? formatDistance;
|
||||
@@ -30,6 +31,7 @@ class ContactTile extends StatelessWidget {
|
||||
const ContactTile({
|
||||
super.key,
|
||||
required this.contact,
|
||||
this.groupLabel,
|
||||
this.currentPosition,
|
||||
this.calculateDistance,
|
||||
this.formatDistance,
|
||||
@@ -133,11 +135,12 @@ class ContactTile extends StatelessWidget {
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
_buildMetaPill(
|
||||
context,
|
||||
icon: _contactTypeIcon(contact),
|
||||
label: contact.type.displayName,
|
||||
),
|
||||
if (groupLabel case final label?)
|
||||
_buildMetaPill(
|
||||
context,
|
||||
icon: Icons.folder_copy_outlined,
|
||||
label: label,
|
||||
),
|
||||
_buildMetaPill(
|
||||
context,
|
||||
icon: Icons.key_outlined,
|
||||
@@ -722,21 +725,6 @@ class ContactTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
IconData _contactTypeIcon(Contact contact) {
|
||||
switch (contact.type) {
|
||||
case ContactType.chat:
|
||||
return Icons.person_outline;
|
||||
case ContactType.repeater:
|
||||
return Icons.router_outlined;
|
||||
case ContactType.room:
|
||||
return Icons.meeting_room_outlined;
|
||||
case ContactType.channel:
|
||||
return Icons.campaign_outlined;
|
||||
case ContactType.none:
|
||||
return Icons.help_outline;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLocationLine(
|
||||
BuildContext context, {
|
||||
required double latitude,
|
||||
|
||||
Reference in New Issue
Block a user