Fix retry flow and contact filters

This commit is contained in:
Janez T
2026-03-12 14:39:44 +01:00
parent 2d1e6c0ca4
commit abb73a4c65
13 changed files with 1145 additions and 153 deletions

View File

@@ -1,19 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import 'dart:math' as math;
import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../providers/app_provider.dart';
import '../../services/contact_route_resolver.dart';
import '../../services/route_hash_preferences.dart';
class ContactRouteDialogResult {
final ParsedContactRoute? route;
final bool shouldClear;
final LatLng? inferredFallbackLocation;
const ContactRouteDialogResult._({this.route, required this.shouldClear});
const ContactRouteDialogResult._({
this.route,
required this.shouldClear,
this.inferredFallbackLocation,
});
const ContactRouteDialogResult.set(ParsedContactRoute route)
: this._(route: route, shouldClear: false);
const ContactRouteDialogResult.setWithFallback(
ParsedContactRoute route, {
LatLng? inferredFallbackLocation,
}) : this._(
route: route,
shouldClear: false,
inferredFallbackLocation: inferredFallbackLocation,
);
const ContactRouteDialogResult.clear() : this._(shouldClear: true);
}
@@ -60,6 +79,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
ParsedContactRoute? _parsedRoute;
String? _errorText;
bool _showRoutingInfo = false;
List<Contact> _selectedMapHops = const [];
@override
void initState() {
@@ -107,6 +127,36 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
}
}
List<Contact> get _routeCandidates =>
widget.availableContacts
.where(
(contact) => contact.isRepeater && contact.displayLocation != null,
)
.toList()
..sort((a, b) => a.displayName.compareTo(b.displayName));
void _syncMapSelectionFromController() {
final tokens = _controller.text
.trim()
.split(',')
.map((token) => token.trim().toUpperCase())
.where((token) => token.isNotEmpty)
.toList();
final selected = <Contact>[];
final seen = <String>{};
for (final token in tokens) {
final match = _routeCandidates
.where(
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
)
.firstOrNull;
if (match != null && seen.add(match.publicKeyHex)) {
selected.add(match);
}
}
_selectedMapHops = selected;
}
Future<void> _loadHashSizePreference() async {
final hashSize = await RouteHashPreferences.getHashSize();
if (!mounted) return;
@@ -114,6 +164,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_selectedHashSize = hashSize;
});
_reparse();
_syncMapSelectionFromController();
}
String _tokenFor(Contact contact, int hashSize) {
@@ -125,23 +176,168 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
return hex.substring(0, length);
}
void _appendHop(Contact contact) {
final token = _tokenFor(contact, _selectedHashSize);
final current = _controller.text.trim();
_controller.text = current.isEmpty ? token : '$current,$token';
void _syncControllerFromSelectedHops() {
final tokens = _selectedMapHops
.map((contact) => _tokenFor(contact, _selectedHashSize))
.toList();
_controller.text = tokens.join(',');
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
}
void _toggleHop(Contact contact) {
setState(() {
if (_selectedMapHops.any(
(item) => item.publicKeyHex == contact.publicKeyHex,
)) {
_selectedMapHops = _selectedMapHops
.where((item) => item.publicKeyHex != contact.publicKeyHex)
.toList();
} else {
_selectedMapHops = [..._selectedMapHops, contact];
}
_syncControllerFromSelectedHops();
_reparse();
});
}
void _applyResolvedPlan(ResolvedContactRoutePlan plan) {
setState(() {
_selectedMapHops = plan.selectedContacts;
_controller.text = plan.canonicalText;
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
_errorText = null;
});
_reparse();
}
LatLng? _resolveLastHopLocation() {
if (_selectedMapHops.isNotEmpty) {
return _selectedMapHops.last.displayLocation == null
? null
: LatLng(
_selectedMapHops.last.displayLocation!.latitude,
_selectedMapHops.last.displayLocation!.longitude,
);
}
final tokens = _controller.text
.trim()
.split(',')
.map((token) => token.trim().toUpperCase())
.where((token) => token.isNotEmpty)
.toList();
if (tokens.isEmpty) return null;
final lastToken = tokens.last;
final match = _routeCandidates
.where(
(contact) => contact.publicKeyHex.toUpperCase().startsWith(lastToken),
)
.firstOrNull;
final location = match?.displayLocation;
if (location == null) return null;
return LatLng(location.latitude, location.longitude);
}
LatLng? _buildSyntheticFallbackLocation() {
final lastHopLocation = _resolveLastHopLocation();
if (lastHopLocation == null) return null;
final seed = widget.contact.publicKey.fold<int>(
_controller.text.codeUnits.fold<int>(0, (sum, unit) => sum + unit),
(sum, byte) => sum + byte,
);
final angle = (seed % 360) * (math.pi / 180.0);
const radiusMeters = 500.0;
final latOffset = (radiusMeters / 111320.0) * math.cos(angle);
final lonDenominator =
111320.0 * math.cos(lastHopLocation.latitude * (math.pi / 180.0));
final lonOffset = lonDenominator.abs() < 1e-6
? 0.0
: (radiusMeters / lonDenominator) * math.sin(angle);
return LatLng(
lastHopLocation.latitude + latOffset,
lastHopLocation.longitude + lonOffset,
);
}
void _resolvePathAutomatically() {
final connectionProvider = context.read<ConnectionProvider>();
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
final recipientLocation = widget.contact.displayLocation;
if (advLat == null ||
advLon == null ||
(advLat == 0 && advLon == 0) ||
recipientLocation == null) {
setState(() {
_errorText =
'Automatic resolve needs both your advertised location and the contact location.';
});
return;
}
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: LatLng(advLat / 1e6, advLon / 1e6),
recipient: widget.contact,
availableContacts: widget.availableContacts,
hashSize: _selectedHashSize,
);
if (plan == null) {
setState(() {
_errorText =
'Could not resolve a route from available repeater locations.';
});
return;
}
_applyResolvedPlan(plan);
}
@override
Widget build(BuildContext context) {
final appProvider = context.watch<AppProvider>();
final routeCandidates =
widget.availableContacts
.where((contact) => contact.isRepeater || contact.isRoom)
.toList()
..sort((a, b) => a.displayName.compareTo(b.displayName));
final routeCandidates = _routeCandidates;
final connectionProvider = context.watch<ConnectionProvider>();
final selfPoint =
connectionProvider.deviceInfo.advLat != null &&
connectionProvider.deviceInfo.advLon != null &&
!(connectionProvider.deviceInfo.advLat == 0 &&
connectionProvider.deviceInfo.advLon == 0)
? LatLng(
connectionProvider.deviceInfo.advLat! / 1e6,
connectionProvider.deviceInfo.advLon! / 1e6,
)
: null;
final recipientLocation = widget.contact.displayLocation;
final recipientPoint = recipientLocation == null
? null
: LatLng(recipientLocation.latitude, recipientLocation.longitude);
final routePoints = <LatLng>[
...?selfPoint == null ? null : [selfPoint],
..._selectedMapHops
.where((contact) => contact.displayLocation != null)
.map(
(contact) => LatLng(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
...?recipientPoint == null ? null : [recipientPoint],
];
final mapPoints = <LatLng>[
...?selfPoint == null ? null : [selfPoint],
...?recipientPoint == null ? null : [recipientPoint],
...routeCandidates.map(
(contact) => LatLng(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
];
return FractionallySizedBox(
heightFactor: 0.85,
@@ -155,83 +351,193 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
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),
_AutomationRoutingInfo(
isExpanded: _showRoutingInfo,
onToggle: () {
setState(() {
_showRoutingInfo = !_showRoutingInfo;
});
},
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled:
appProvider.nearestRelayFallbackEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
),
const SizedBox(height: 16),
Text(
'Pick hops from contacts',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
if (routeCandidates.isEmpty)
const Text(
'No repeater or room contacts are available for route building.',
)
else
Expanded(
child: ListView.builder(
itemCount: routeCandidates.length,
itemBuilder: (context, index) {
final candidate = routeCandidates[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(candidate.displayName),
trailing: TextButton(
onPressed: () => _appendHop(candidate),
child: Text(
'Use ${_tokenFor(candidate, _selectedHashSize)}',
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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,
),
),
],
),
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: 16),
_AutomationRoutingInfo(
isExpanded: _showRoutingInfo,
onToggle: () {
setState(() {
_showRoutingInfo = !_showRoutingInfo;
});
},
autoRouteRotationEnabled:
appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled:
appProvider.nearestRelayFallbackEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
),
const SizedBox(height: 16),
],
),
),
const SizedBox(height: 16),
Row(
),
OverflowBar(
alignment: MainAxisAlignment.spaceBetween,
spacing: 8,
overflowSpacing: 8,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
@@ -244,13 +550,16 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
).pop(const ContactRouteDialogResult.clear()),
child: const Text('Clear Route'),
),
const Spacer(),
FilledButton(
onPressed: _parsedRoute == null
? null
: () => Navigator.of(
context,
).pop(ContactRouteDialogResult.set(_parsedRoute!)),
: () => Navigator.of(context).pop(
ContactRouteDialogResult.setWithFallback(
_parsedRoute!,
inferredFallbackLocation:
_buildSyntheticFallbackLocation(),
),
),
child: const Text('Set Route'),
),
],
@@ -262,6 +571,31 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
}
}
class _RouteMarkerDot extends StatelessWidget {
final String label;
final Color color;
const _RouteMarkerDot({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Tooltip(
message: label,
child: Center(
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
);
}
}
class _AutomationRoutingInfo extends StatelessWidget {
final bool isExpanded;
final VoidCallback onToggle;

View File

@@ -128,6 +128,24 @@ class ContactTile extends StatelessWidget {
final Widget subtitleWidget = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_buildMetaPill(
context,
icon: _contactTypeIcon(contact),
label: contact.type.displayName,
),
_buildMetaPill(
context,
icon: Icons.key_outlined,
label: contact.publicKeyShort,
monospace: true,
),
],
),
if (location != null) ...[
const SizedBox(height: 2),
_buildLocationLine(
@@ -591,6 +609,7 @@ class ContactTile extends StatelessWidget {
contact.publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes,
inferredFallbackLocation: routeResult.inferredFallbackLocation,
);
try {
@@ -671,6 +690,53 @@ class ContactTile extends StatelessWidget {
);
}
Widget _buildMetaPill(
BuildContext context, {
required IconData icon,
required String label,
bool monospace = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
fontFamily: monospace ? 'monospace' : null,
),
),
],
),
);
}
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,