mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add swarm mode transport doc
This commit is contained in:
230
lib/widgets/contacts/contact_route_dialog.dart
Normal file
230
lib/widgets/contacts/contact_route_dialog.dart
Normal file
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
|
||||
class ContactRouteDialog extends StatefulWidget {
|
||||
final Contact contact;
|
||||
final List<Contact> availableContacts;
|
||||
|
||||
const ContactRouteDialog({
|
||||
super.key,
|
||||
required this.contact,
|
||||
required this.availableContacts,
|
||||
});
|
||||
|
||||
static Future<ParsedContactRoute?> show(
|
||||
BuildContext context, {
|
||||
required Contact contact,
|
||||
required List<Contact> availableContacts,
|
||||
}) {
|
||||
return showDialog<ParsedContactRoute>(
|
||||
context: context,
|
||||
builder: (context) => ContactRouteDialog(
|
||||
contact: contact,
|
||||
availableContacts: availableContacts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ContactRouteDialog> createState() => _ContactRouteDialogState();
|
||||
}
|
||||
|
||||
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
late final TextEditingController _controller;
|
||||
late int _selectedHashSize;
|
||||
ParsedContactRoute? _parsedRoute;
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedHashSize = widget.contact.routeHasPath
|
||||
? widget.contact.routeHashSize
|
||||
: 1;
|
||||
_controller = TextEditingController(
|
||||
text: widget.contact.routeCanonicalText,
|
||||
);
|
||||
_controller.addListener(_reparse);
|
||||
_reparse();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller
|
||||
..removeListener(_reparse)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reparse() {
|
||||
final input = _controller.text.trim();
|
||||
if (input.isEmpty) {
|
||||
setState(() {
|
||||
_parsedRoute = null;
|
||||
_errorText = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final parsed = ContactRouteCodec.parse(input);
|
||||
setState(() {
|
||||
_parsedRoute = parsed;
|
||||
_selectedHashSize = parsed.hashSize;
|
||||
_errorText = null;
|
||||
});
|
||||
} on ContactRouteFormatException catch (error) {
|
||||
setState(() {
|
||||
_parsedRoute = null;
|
||||
_errorText = error.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _tokenFor(Contact contact, int hashSize) {
|
||||
final hex = contact.publicKeyHex.toUpperCase();
|
||||
final length = hashSize * 2;
|
||||
if (hex.length < length) {
|
||||
return hex;
|
||||
}
|
||||
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';
|
||||
_controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _controller.text.length),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final routeCandidates =
|
||||
widget.availableContacts
|
||||
.where((contact) => contact.isRepeater || contact.isRoom)
|
||||
.toList()
|
||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('Set Route for ${widget.contact.displayName}'),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Path hash size',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [1, 2, 3]
|
||||
.map(
|
||||
(hashSize) => ChoiceChip(
|
||||
label: Text(
|
||||
'$hashSize byte${hashSize == 1 ? '' : 's'}',
|
||||
),
|
||||
selected: _selectedHashSize == hashSize,
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
_selectedHashSize = hashSize;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
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. 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),
|
||||
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
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: routeCandidates.length,
|
||||
itemBuilder: (context, index) {
|
||||
final candidate = routeCandidates[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(candidate.displayName),
|
||||
subtitle: Text(
|
||||
'1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}',
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _appendHop(candidate),
|
||||
child: Text(
|
||||
'Use ${_tokenFor(candidate, _selectedHashSize)}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _parsedRoute == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(_parsedRoute),
|
||||
child: const Text('Set Route'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/map_provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import 'contact_route_dialog.dart';
|
||||
import 'direct_message_sheet.dart';
|
||||
import 'room_login_sheet.dart';
|
||||
import '../../utils/location_formats.dart';
|
||||
@@ -52,6 +53,7 @@ class ContactTile extends StatelessWidget {
|
||||
contact.telemetry != null && contact.telemetry!.isRecent;
|
||||
final battery = contact.displayBattery;
|
||||
final location = contact.displayLocation;
|
||||
final routeHasPath = contact.routeHasPath;
|
||||
|
||||
// Calculate distance if both positions are available
|
||||
String? distanceText;
|
||||
@@ -159,12 +161,12 @@ class ContactTile extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: contact.hasPath
|
||||
color: routeHasPath
|
||||
? Colors.green.withValues(alpha: 0.15)
|
||||
: Colors.orange.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(
|
||||
color: contact.hasPath ? Colors.green : Colors.orange,
|
||||
color: routeHasPath ? Colors.green : Colors.orange,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
@@ -172,19 +174,19 @@ class ContactTile extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
contact.hasPath ? Icons.route : Icons.waves,
|
||||
routeHasPath ? Icons.route : Icons.waves,
|
||||
size: 10,
|
||||
color: contact.hasPath ? Colors.green : Colors.orange,
|
||||
color: routeHasPath ? Colors.green : Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
contact.hasPath
|
||||
routeHasPath
|
||||
? AppLocalizations.of(context)!.direct
|
||||
: AppLocalizations.of(context)!.flood,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: contact.hasPath ? Colors.green : Colors.orange,
|
||||
color: routeHasPath ? Colors.green : Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -478,7 +480,7 @@ class ContactTile extends StatelessWidget {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
// Determine if we should use flooding (no path) or direct (has path)
|
||||
final hasPath = contact.hasPath;
|
||||
final hasPath = contact.routeHasPath;
|
||||
|
||||
// Use smart ping with automatic fallback
|
||||
final result = await connectionProvider.smartPing(
|
||||
@@ -622,407 +624,485 @@ class ContactTile extends StatelessWidget {
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
final contactsProvider = context.watch<ContactsProvider>();
|
||||
final currentContact =
|
||||
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
|
||||
final isPingInProgress = context
|
||||
.watch<ConnectionProvider>()
|
||||
.isPingInProgress(contact.publicKey);
|
||||
return Column(
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 8, bottom: 16),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
// Handle bar
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 8, bottom: 16),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type, context),
|
||||
child: contact.roleEmoji != null
|
||||
? Text(
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
)
|
||||
: Icon(_getTypeIcon(contact.type), color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type, context),
|
||||
child: contact.roleEmoji != null
|
||||
? Text(
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
)
|
||||
: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Content
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_detailRow(l10n.type, contact.type.displayName),
|
||||
if (contact.isChannel) ...[
|
||||
_detailRow(
|
||||
l10n.channel,
|
||||
contact.getLocalizedDisplayName(context),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
if (!contact.isPublicChannel)
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// Content
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_detailRow(l10n.type, contact.type.displayName),
|
||||
if (contact.isChannel) ...[
|
||||
_detailRow(
|
||||
'Slot',
|
||||
'${l10n.channel} ${contact.publicKey.length > 1 ? contact.publicKey[1] : '-'}',
|
||||
l10n.channel,
|
||||
contact.getLocalizedDisplayName(context),
|
||||
),
|
||||
] else
|
||||
// Public Key with copy button
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
'${l10n.publicKey}:',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
if (!contact.isPublicChannel)
|
||||
_detailRow(
|
||||
'Slot',
|
||||
'${l10n.channel} ${contact.publicKey.length > 1 ? contact.publicKey[1] : '-'}',
|
||||
),
|
||||
] else
|
||||
// Public Key with copy button
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
'${l10n.publicKey}:',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(contact.publicKeyShort)),
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: contact.publicKeyHex),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.publicKeyCopied),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
Icons.copy,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_detailRow(
|
||||
l10n.lastSeen,
|
||||
_getLocalizedTimeSinceLastSeen(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Room Login Status
|
||||
if (roomLoginState != null) ...[
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.roomStatus}:',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.loginStatus,
|
||||
roomLoginState.isLoggedIn
|
||||
? AppLocalizations.of(context)!.loggedIn
|
||||
: AppLocalizations.of(context)!.notLoggedIn,
|
||||
),
|
||||
if (roomLoginState.isLoggedIn) ...[
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.adminAccess,
|
||||
roomLoginState.isAdmin
|
||||
? AppLocalizations.of(context)!.yes
|
||||
: AppLocalizations.of(context)!.no,
|
||||
),
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.permissions,
|
||||
roomLoginState.permissions.toString(),
|
||||
),
|
||||
if (roomLoginState.loginDurationFormatted != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.loggedIn,
|
||||
roomLoginState.loginDurationFormatted!,
|
||||
),
|
||||
Expanded(child: Text(contact.publicKeyShort)),
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: contact.publicKeyHex),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.publicKeyCopied),
|
||||
duration: const Duration(seconds: 2),
|
||||
],
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.passwordSaved,
|
||||
roomLoginState.hasPassword
|
||||
? AppLocalizations.of(context)!.yes
|
||||
: AppLocalizations.of(context)!.no,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (contact.displayLocation != null) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.locationColon,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
// Navigate to map and close modal
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: LatLng(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
|
||||
// Switch to map tab using callback
|
||||
onNavigateToMap?.call();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
Icons.copy,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
icon: const Icon(Icons.map, size: 18),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.viewOnMap,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_detailRow(
|
||||
l10n.lastSeen,
|
||||
_getLocalizedTimeSinceLastSeen(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Room Login Status
|
||||
if (roomLoginState != null) ...[
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.roomStatus}:',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
const SizedBox(height: 8),
|
||||
// Decimal Degrees (DD)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'DD',
|
||||
'${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.loginStatus,
|
||||
roomLoginState.isLoggedIn
|
||||
? AppLocalizations.of(context)!.loggedIn
|
||||
: AppLocalizations.of(context)!.notLoggedIn,
|
||||
),
|
||||
if (roomLoginState.isLoggedIn) ...[
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.adminAccess,
|
||||
roomLoginState.isAdmin
|
||||
? AppLocalizations.of(context)!.yes
|
||||
: AppLocalizations.of(context)!.no,
|
||||
),
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.permissions,
|
||||
roomLoginState.permissions.toString(),
|
||||
),
|
||||
if (roomLoginState.loginDurationFormatted != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.loggedIn,
|
||||
roomLoginState.loginDurationFormatted!,
|
||||
// Degrees Minutes Seconds (DMS)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'DMS',
|
||||
_convertToDMS(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// Degrees Decimal Minutes (DDM)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'DDM',
|
||||
_convertToDDM(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// MGRS (Military Grid Reference System)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'MGRS',
|
||||
_convertToMGRS(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// Google Plus Code
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'Plus Code',
|
||||
formatPlusCode(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.passwordSaved,
|
||||
roomLoginState.hasPassword
|
||||
? AppLocalizations.of(context)!.yes
|
||||
: AppLocalizations.of(context)!.no,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (contact.displayLocation != null) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.locationColon,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
if (contact.telemetry != null) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.telemetry}:',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
// Navigate to map and close modal
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: LatLng(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
TextButton.icon(
|
||||
onPressed: isPingInProgress
|
||||
? null
|
||||
: () {
|
||||
final connectionProvider = context
|
||||
.read<ConnectionProvider>();
|
||||
connectionProvider.requestTelemetry(
|
||||
contact.publicKey,
|
||||
zeroHop: true,
|
||||
);
|
||||
},
|
||||
icon: isPingInProgress
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(AppLocalizations.of(context)!.refresh),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
|
||||
// Switch to map tab using callback
|
||||
onNavigateToMap?.call();
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (contact.telemetry!.batteryMilliVolts != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.voltage,
|
||||
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
|
||||
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
|
||||
)
|
||||
else if (contact.telemetry!.batteryPercentage != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.battery,
|
||||
'${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%',
|
||||
),
|
||||
if (contact.telemetry!.temperature != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.temperature,
|
||||
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
|
||||
),
|
||||
if (contact.telemetry!.humidity != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.humidity,
|
||||
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
|
||||
),
|
||||
if (contact.telemetry!.pressure != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.pressure,
|
||||
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
|
||||
),
|
||||
if (contact.telemetry!.gpsLocation != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.gpsTelemetry,
|
||||
'${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.updated,
|
||||
'${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})',
|
||||
),
|
||||
],
|
||||
if (!currentContact.isChannel) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Route',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_detailRow('Mode', currentContact.routeSummary),
|
||||
if (currentContact.routeHopCount > 0)
|
||||
_detailRow('Route', currentContact.routeCanonicalText),
|
||||
if (currentContact.routeHopCount > 0)
|
||||
_detailRow(
|
||||
'Descriptor',
|
||||
'0x${currentContact.routeEncodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
_showSetRouteDialog(context, currentContact),
|
||||
icon: const Icon(Icons.route),
|
||||
label: const Text('Set Route'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: currentContact.isPublicChannel
|
||||
? null
|
||||
: () async {
|
||||
contactsProvider.resetContactRouteLocal(
|
||||
currentContact.publicKey,
|
||||
);
|
||||
try {
|
||||
await connectionProvider.resetPath(
|
||||
currentContact.publicKey,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.pathResetInfo(
|
||||
currentContact.displayName,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
contactsProvider.setContactRouteLocal(
|
||||
currentContact.publicKey,
|
||||
signedEncodedPathLen:
|
||||
currentContact.routeSignedPathLen,
|
||||
paddedPathBytes:
|
||||
currentContact.outPath,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(
|
||||
context,
|
||||
'Failed to reset route.',
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.resetPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// Direct Message button for chat contacts
|
||||
if (contact.type == ContactType.chat) ...[
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context); // Close details first
|
||||
_showDirectMessageDialog(context, contact);
|
||||
},
|
||||
icon: const Icon(Icons.map, size: 18),
|
||||
label: Text(AppLocalizations.of(context)!.viewOnMap),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
icon: const Icon(Icons.message),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.sendDirectMessage,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: _getTypeColor(
|
||||
contact.type,
|
||||
context,
|
||||
),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Decimal Degrees (DD)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'DD',
|
||||
'${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
// Degrees Minutes Seconds (DMS)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'DMS',
|
||||
_convertToDMS(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// Degrees Decimal Minutes (DDM)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'DDM',
|
||||
_convertToDDM(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// MGRS (Military Grid Reference System)
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'MGRS',
|
||||
_convertToMGRS(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
// Google Plus Code
|
||||
_detailRowWithCopy(
|
||||
context,
|
||||
'Plus Code',
|
||||
formatPlusCode(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (contact.telemetry != null) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.telemetry}:',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
],
|
||||
// Room Login button for room contacts (except Public Channel)
|
||||
if (contact.type == ContactType.room &&
|
||||
!contact.isPublicChannel) ...[
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context); // Close details first
|
||||
_showRoomLoginDialog(context, contact);
|
||||
},
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(
|
||||
roomLoginState?.isLoggedIn == true
|
||||
? AppLocalizations.of(context)!.reLoginToRoom
|
||||
: AppLocalizations.of(context)!.loginToRoom,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: isPingInProgress
|
||||
? null
|
||||
: () {
|
||||
final connectionProvider = context
|
||||
.read<ConnectionProvider>();
|
||||
connectionProvider.requestTelemetry(
|
||||
contact.publicKey,
|
||||
zeroHop: true,
|
||||
);
|
||||
},
|
||||
icon: isPingInProgress
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(AppLocalizations.of(context)!.refresh),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: _getTypeColor(
|
||||
contact.type,
|
||||
context,
|
||||
),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (contact.telemetry!.batteryMilliVolts != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.voltage,
|
||||
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
|
||||
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
|
||||
)
|
||||
else if (contact.telemetry!.batteryPercentage != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.battery,
|
||||
'${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%',
|
||||
),
|
||||
if (contact.telemetry!.temperature != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.temperature,
|
||||
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
|
||||
),
|
||||
if (contact.telemetry!.humidity != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.humidity,
|
||||
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
|
||||
),
|
||||
if (contact.telemetry!.pressure != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.pressure,
|
||||
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
|
||||
),
|
||||
if (contact.telemetry!.gpsLocation != null)
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.gpsTelemetry,
|
||||
'${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
_detailRow(
|
||||
AppLocalizations.of(context)!.updated,
|
||||
'${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})',
|
||||
),
|
||||
],
|
||||
// Direct Message button for chat contacts
|
||||
if (contact.type == ContactType.chat) ...[
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context); // Close details first
|
||||
_showDirectMessageDialog(context, contact);
|
||||
},
|
||||
icon: const Icon(Icons.message),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.sendDirectMessage,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: _getTypeColor(contact.type, context),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
connectionProvider.resetPath(contact.publicKey);
|
||||
},
|
||||
icon: const Icon(Icons.route),
|
||||
label: Text(AppLocalizations.of(context)!.resetPath),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: BorderSide(
|
||||
color: _getTypeColor(contact.type, context),
|
||||
],
|
||||
// Delete Contact button (for all contact types except Public Channel)
|
||||
if (!contact.isPublicChannel) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
_showDeleteConfirmation(context, contact),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.deleteContact,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: const BorderSide(color: Colors.red),
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
foregroundColor: _getTypeColor(contact.type, context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
// Room Login button for room contacts (except Public Channel)
|
||||
if (contact.type == ContactType.room &&
|
||||
!contact.isPublicChannel) ...[
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context); // Close details first
|
||||
_showRoomLoginDialog(context, contact);
|
||||
},
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(
|
||||
roomLoginState?.isLoggedIn == true
|
||||
? AppLocalizations.of(context)!.reLoginToRoom
|
||||
: AppLocalizations.of(context)!.loginToRoom,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: _getTypeColor(contact.type, context),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Delete Contact button (for all contact types except Public Channel)
|
||||
if (!contact.isPublicChannel) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
_showDeleteConfirmation(context, contact),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.deleteContact,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: const BorderSide(color: Colors.red),
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -1030,6 +1110,56 @@ class ContactTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showSetRouteDialog(
|
||||
BuildContext context,
|
||||
Contact contact,
|
||||
) async {
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final availableContacts = contactsProvider.contacts
|
||||
.where((candidate) => candidate.publicKeyHex != contact.publicKeyHex)
|
||||
.toList();
|
||||
|
||||
final parsedRoute = await ContactRouteDialog.show(
|
||||
context,
|
||||
contact: contact,
|
||||
availableContacts: availableContacts,
|
||||
);
|
||||
if (parsedRoute == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final previousSignedPathLen = contact.routeSignedPathLen;
|
||||
final previousPathBytes = Uint8List.fromList(contact.outPath);
|
||||
contactsProvider.setContactRouteLocal(
|
||||
contact.publicKey,
|
||||
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
||||
paddedPathBytes: parsedRoute.paddedPathBytes,
|
||||
);
|
||||
|
||||
try {
|
||||
await connectionProvider.setContactRoute(
|
||||
contact,
|
||||
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
||||
paddedPathBytes: parsedRoute.paddedPathBytes,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Route set: ${parsedRoute.canonicalText}')),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
contactsProvider.setContactRouteLocal(
|
||||
contact.publicKey,
|
||||
signedEncodedPathLen: previousSignedPathLen,
|
||||
paddedPathBytes: previousPathBytes,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ToastLogger.error(context, 'Failed to set route: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _detailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
|
||||
Reference in New Issue
Block a user