Fix voice quality regression

This commit is contained in:
Janez T
2026-03-12 19:38:41 +01:00
parent baa1aa6edd
commit d2e6b692f3
14 changed files with 1023 additions and 301 deletions

View File

@@ -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;

View File

@@ -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,