mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix retry flow and contact filters
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1788,6 +1788,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
receptionDetails?.rssiDbm ??
|
||||
matchedRxLog?.logRxDataInfo?.rssiDbm ??
|
||||
message.lastEchoRssiDbm;
|
||||
final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id);
|
||||
|
||||
// Look up contact information for rich display name
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
@@ -2515,7 +2516,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
switch (recipient.deliveryStatus) {
|
||||
case MessageDeliveryStatus.delivered:
|
||||
statusColor = Colors.green;
|
||||
statusIcon = Icons.check_circle;
|
||||
statusIcon = Icons.done_all;
|
||||
statusText =
|
||||
recipient.roundTripTimeMs != null
|
||||
? '${recipient.roundTripTimeMs}ms'
|
||||
@@ -2523,6 +2524,15 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
context,
|
||||
)!.delivered;
|
||||
break;
|
||||
case MessageDeliveryStatus.sent:
|
||||
statusColor = Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant;
|
||||
statusIcon = Icons.done;
|
||||
statusText = AppLocalizations.of(
|
||||
context,
|
||||
)!.sent;
|
||||
break;
|
||||
case MessageDeliveryStatus.failed:
|
||||
statusColor = Colors.red;
|
||||
statusIcon = Icons.cancel;
|
||||
@@ -2531,7 +2541,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
)!.failed;
|
||||
break;
|
||||
case MessageDeliveryStatus.sending:
|
||||
case MessageDeliveryStatus.sent:
|
||||
default:
|
||||
statusColor = Colors.orange;
|
||||
statusIcon = Icons.schedule;
|
||||
@@ -2726,6 +2735,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
context,
|
||||
message: message,
|
||||
isSarMarker: isSarMarker,
|
||||
routeMetadata: routeMetadata,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../models/message_route_metadata.dart';
|
||||
import '../../utils/avatar_label_helper.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
@@ -59,6 +60,7 @@ Widget buildBubbleMetaFooter(
|
||||
BuildContext context, {
|
||||
required Message message,
|
||||
required bool isSarMarker,
|
||||
MessageRouteMetadata? routeMetadata,
|
||||
}) {
|
||||
final metaColor = Theme.of(
|
||||
context,
|
||||
@@ -86,12 +88,13 @@ Widget buildBubbleMetaFooter(
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
]);
|
||||
} else if (!isSarMarker && message.pathLen < 255) {
|
||||
} else if (!isSarMarker && _effectivePathLen(message, routeMetadata) < 255) {
|
||||
final effectivePathLen = _effectivePathLen(message, routeMetadata);
|
||||
items.addAll([
|
||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
||||
effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
@@ -124,6 +127,9 @@ Widget buildBubbleMetaFooter(
|
||||
);
|
||||
}
|
||||
|
||||
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) =>
|
||||
routeMetadata?.hopCount ?? message.pathLen;
|
||||
|
||||
Widget buildChannelHeaderPill(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../models/message_route_metadata.dart';
|
||||
import '../../models/path_selection.dart';
|
||||
import '../../models/message_reception_details.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
@@ -209,7 +210,7 @@ Widget buildSentDirectSignalStatus(
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.alt_route,
|
||||
label: hopDisplayLabel(message),
|
||||
label: hopDisplayLabelForMessage(message, routeMetadata),
|
||||
color: Colors.indigo,
|
||||
),
|
||||
_techChip(
|
||||
@@ -294,6 +295,17 @@ String hopDisplayLabel(Message message) {
|
||||
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
String hopDisplayLabelForMessage(
|
||||
Message message,
|
||||
MessageRouteMetadata? routeMetadata,
|
||||
) {
|
||||
final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen;
|
||||
if (effectivePathLen == 0) return 'Direct';
|
||||
if (effectivePathLen >= 255 && message.isContactMessage) return 'Direct';
|
||||
if (effectivePathLen >= 255) return 'Unknown';
|
||||
return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
Widget _techChip(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
|
||||
Reference in New Issue
Block a user