feat: Add relay ping and lock message destination

This commit is contained in:
Janez T
2026-03-31 17:52:44 +02:00
parent 7df750c8d5
commit 3392e5f9c1
9 changed files with 955 additions and 123 deletions

View File

@@ -11,6 +11,7 @@ import '../../providers/contacts_provider.dart';
import '../../providers/map_provider.dart';
import '../../providers/messages_provider.dart';
import '../../providers/sensors_provider.dart';
import '../../services/location_tracking_service.dart';
import '../../services/message_destination_preferences.dart';
import 'contact_route_dialog.dart';
import 'contact_trace_sheet.dart';
@@ -529,6 +530,16 @@ class ContactTile extends StatelessWidget {
_showNeighbours(context, contact);
},
),
if (contact.type == ContactType.repeater ||
contact.type == ContactType.room)
_ContactSheetAction(
icon: Icons.network_ping,
label: 'Ping',
onTap: () async {
Navigator.pop(context);
_pingRelay(context, contact);
},
),
if (!contact.isPublicChannel)
_ContactSheetAction(
icon: Icons.edit_outlined,
@@ -664,6 +675,18 @@ class ContactTile extends StatelessWidget {
);
}
void _pingRelay(BuildContext context, Contact contact) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => _PingRelaySheet(contact: contact),
);
}
void _showDeleteConfirmation(
BuildContext context,
Contact contact, {
@@ -2325,3 +2348,181 @@ class _MappedNeighbour {
required this.location,
});
}
/// Bottom sheet for pinging a relay/repeater with history and distance.
class _PingRelaySheet extends StatefulWidget {
final Contact contact;
const _PingRelaySheet({required this.contact});
@override
State<_PingRelaySheet> createState() => _PingRelaySheetState();
}
class _PingRelaySheetState extends State<_PingRelaySheet> {
bool _pinging = false;
final List<RelayPingResult> _history = [];
@override
void initState() {
super.initState();
_doPing();
}
Future<void> _doPing() async {
setState(() => _pinging = true);
final connectionProvider = context.read<ConnectionProvider>();
final result = await connectionProvider.pingRelay(widget.contact);
if (!mounted) return;
setState(() {
_pinging = false;
_history.insert(0, result);
});
}
String? _distanceText() {
final location = widget.contact.displayLocation;
if (location == null) return null;
final currentPosition =
LocationTrackingService().currentPosition;
if (currentPosition == null) return null;
final meters = Geolocator.distanceBetween(
currentPosition.latitude,
currentPosition.longitude,
location.latitude,
location.longitude,
);
if (meters < 1000) return '${meters.round()} m';
if (meters < 10000) return '${(meters / 1000).toStringAsFixed(2)} km';
return '${(meters / 1000).toStringAsFixed(1)} km';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final displayName = widget.contact.displayName;
final distance = _distanceText();
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: Text(
'Ping $displayName',
style: theme.textTheme.titleLarge,
),
),
FilledButton.icon(
onPressed: _pinging ? null : _doPing,
icon: _pinging
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.refresh, size: 18),
label: Text(_pinging ? 'Pinging...' : 'Ping Again'),
),
],
),
if (distance != null) ...[
const SizedBox(height: 4),
Text(
'Distance: $distance',
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 12),
if (_history.isEmpty && _pinging)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (_history.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'No results yet',
style: theme.textTheme.bodyMedium,
),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: ListView.separated(
shrinkWrap: true,
itemCount: _history.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final r = _history[index];
final seq = _history.length - index;
if (!r.success) {
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 14,
backgroundColor: theme.colorScheme.error,
child: Text(
'$seq',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
title: const Text('Timeout'),
subtitle: const Text('No response received'),
);
}
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 14,
backgroundColor: Colors.green,
child: Text(
'$seq',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
title: Text('${r.durationMs} ms'),
subtitle: Text(
'SNR there: ${r.snrThere.toStringAsFixed(1)} dB '
'SNR back: ${r.snrBack.toStringAsFixed(1)} dB',
),
);
},
),
),
],
),
),
);
}
}

View File

@@ -19,6 +19,7 @@ class MessagesComposer extends StatelessWidget {
final double bottomPadding;
final String destinationLabel;
final Widget destinationAvatar;
final bool destinationLocked;
final List<Contact> mentionSuggestions;
final String mentionQuery;
final ValueChanged<Contact> onMentionSelected;
@@ -44,6 +45,7 @@ class MessagesComposer extends StatelessWidget {
required this.bottomPadding,
required this.destinationLabel,
required this.destinationAvatar,
required this.destinationLocked,
required this.mentionSuggestions,
required this.mentionQuery,
required this.onMentionSelected,
@@ -118,10 +120,13 @@ class MessagesComposer extends StatelessWidget {
],
const SizedBox(width: 8),
Expanded(
child: _DestinationSelector(
child: _DestinationPill(
destinationLabel: destinationLabel,
destinationAvatar: destinationAvatar,
onTap: onShowRecipientSelector,
isLocked: destinationLocked,
onTap: destinationLocked
? null
: onShowRecipientSelector,
),
),
],
@@ -297,59 +302,73 @@ class _ComposerActionButton extends StatelessWidget {
}
}
class _DestinationSelector extends StatelessWidget {
class _DestinationPill extends StatelessWidget {
final String destinationLabel;
final Widget destinationAvatar;
final VoidCallback onTap;
final bool isLocked;
final VoidCallback? onTap;
const _DestinationSelector({
const _DestinationPill({
required this.destinationLabel,
required this.destinationAvatar,
required this.onTap,
required this.isLocked,
this.onTap,
});
@override
Widget build(BuildContext context) {
final content = Ink(
key: ValueKey(
isLocked
? 'messages_composer_destination_locked'
: 'messages_composer_destination_selector',
),
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 8),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
if (!isLocked)
Icon(
Icons.expand_more_rounded,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
);
if (onTap == null) {
return Material(color: Colors.transparent, child: content);
}
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Ink(
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 8),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more_rounded,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
child: content,
),
);
}