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),
|
||||
|
||||
@@ -3,11 +3,13 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_avif/flutter_avif.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/image_provider.dart' as ip;
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/transmission_target_resolver.dart';
|
||||
import 'transfer_timeout.dart';
|
||||
@@ -33,7 +35,9 @@ class ImageMessageBubble extends StatefulWidget {
|
||||
|
||||
class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
static const int _maxFetchHops = 3;
|
||||
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
|
||||
bool _isRequesting = false;
|
||||
bool _isPartialRequest = false;
|
||||
String? _errorText;
|
||||
Timer? _requestTimeoutTimer;
|
||||
|
||||
@@ -59,6 +63,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
|
||||
return Consumer<ip.ImageProvider>(
|
||||
builder: (context, imageProvider, _) {
|
||||
final transferCount = context.select<MessagesProvider, int>(
|
||||
(provider) => provider.transferCountForSession(
|
||||
imageSessionId: envelope.sessionId,
|
||||
),
|
||||
);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final session = imageProvider.session(envelope.sessionId);
|
||||
final sender = TransmissionTargetResolver.resolveLocalTarget(
|
||||
@@ -66,11 +75,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
);
|
||||
final effectivePathLen = sender != null && sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender != null && sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: widget.message.pathLen;
|
||||
final isComplete = imageProvider.isComplete(envelope.sessionId);
|
||||
final eta = imageProvider.estimateRemainingTransferTime(
|
||||
@@ -82,6 +90,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = null;
|
||||
});
|
||||
}
|
||||
@@ -91,6 +100,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
final received = session?.receivedCount ?? 0;
|
||||
final total = session?.total ?? envelope.total;
|
||||
final imageBytes = isComplete ? session?.imageBytes : null;
|
||||
final fragmentPresence =
|
||||
session?.fragments.map((fragment) => fragment != null).toList() ??
|
||||
List<bool>.filled(total, false);
|
||||
final isReceivingData =
|
||||
!_isRequesting &&
|
||||
!isComplete &&
|
||||
_hasRecentInboundActivity(
|
||||
lastReceivedAt: session?.lastFragmentAt,
|
||||
received: received,
|
||||
total: total,
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isComplete
|
||||
@@ -110,8 +130,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
imageBytes: imageBytes,
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
received: received,
|
||||
total: total,
|
||||
fragmentPresence: fragmentPresence,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
@@ -125,6 +147,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_statusText(
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
isPartialRequest: _isPartialRequest,
|
||||
received: received,
|
||||
total: total,
|
||||
envelope: envelope,
|
||||
@@ -135,6 +159,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
eta: eta,
|
||||
pathLen: effectivePathLen,
|
||||
transferCount: transferCount,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -156,8 +181,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
required Uint8List? imageBytes,
|
||||
required bool isComplete,
|
||||
required bool isRequesting,
|
||||
required bool isReceivingData,
|
||||
required int received,
|
||||
required int total,
|
||||
required List<bool> fragmentPresence,
|
||||
required ImageEnvelope envelope,
|
||||
required int? radioBw,
|
||||
required int? radioSf,
|
||||
@@ -180,19 +207,28 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isRequesting) ...[
|
||||
// Download progress ring.
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CircularProgressIndicator(
|
||||
value: total > 0 ? received / total : null,
|
||||
strokeWidth: 3,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.25),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_PacketBlockProgress(
|
||||
presence: fragmentPresence,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
highlightMissing: _isPartialRequest,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'$received/$total',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$received/$total',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
@@ -229,16 +265,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
] else ...[
|
||||
// Tap-to-load icon.
|
||||
IconButton(
|
||||
onPressed: () => _requestAndFetch(
|
||||
envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: pathLen,
|
||||
onPressed: isReceivingData
|
||||
? null
|
||||
: () => _requestAndFetch(
|
||||
envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: pathLen,
|
||||
),
|
||||
icon: Icon(
|
||||
isReceivingData
|
||||
? Icons.downloading_rounded
|
||||
: Icons.download_rounded,
|
||||
size: 40,
|
||||
),
|
||||
icon: const Icon(Icons.download_rounded, size: 40),
|
||||
color: Colors.white70,
|
||||
tooltip: 'Load image',
|
||||
tooltip: isReceivingData
|
||||
? 'Image is already being received'
|
||||
: 'Load image',
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -255,10 +300,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
int pathLen = 0,
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final conn = context.read<ConnectionProvider>();
|
||||
final imageProvider = context.read<ip.ImageProvider>();
|
||||
@@ -271,7 +312,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
@@ -322,7 +362,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
@@ -364,9 +403,18 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
}
|
||||
}
|
||||
|
||||
if (sender.outPathLen >= 2) {
|
||||
if (!sender.routeSupportsLegacyRawTransport) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch image',
|
||||
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender.routeHopCount >= 2) {
|
||||
_showToast(
|
||||
'Image fetch over ${sender.outPathLen} hops may take a while.',
|
||||
'Image fetch over ${sender.routeHopCount} hops may take a while.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -390,28 +438,31 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
final missing = imageProvider.missingFragmentIndices(envelope.sessionId);
|
||||
final isPartialResume =
|
||||
missing.isNotEmpty && missing.length < envelope.total;
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_isPartialRequest = isPartialResume;
|
||||
_errorText = null;
|
||||
});
|
||||
final request = isPartialResume
|
||||
? ImageFetchRequest(
|
||||
sessionId: envelope.sessionId,
|
||||
want: 'missing',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
)
|
||||
: ImageFetchRequest(
|
||||
sessionId: envelope.sessionId,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
final payload = request.encodeBinary();
|
||||
try {
|
||||
debugPrint(
|
||||
'📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}',
|
||||
'📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
|
||||
);
|
||||
await conn.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
contactPathLen: sender.outPathLen,
|
||||
contactPathLen: sender.routeSignedPathLen,
|
||||
payload: payload,
|
||||
);
|
||||
} catch (_) {
|
||||
@@ -419,6 +470,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_showToast('Image fetch failed to send request');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = 'Image unavailable right now';
|
||||
});
|
||||
}
|
||||
@@ -427,8 +479,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (!mounted) return;
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final effectivePathLen = sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: pathLen;
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
|
||||
@@ -450,6 +502,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_showToast('Image fetch timed out');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = 'Image fetch timed out';
|
||||
});
|
||||
}
|
||||
@@ -471,6 +524,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
_showToast('Image receive canceled');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = 'Image receive canceled';
|
||||
});
|
||||
}
|
||||
@@ -479,6 +533,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -503,6 +558,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
static String _statusText({
|
||||
required bool isComplete,
|
||||
required bool isRequesting,
|
||||
required bool isReceivingData,
|
||||
required bool isPartialRequest,
|
||||
required int received,
|
||||
required int total,
|
||||
required ImageEnvelope envelope,
|
||||
@@ -513,6 +570,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
required String? error,
|
||||
required bool isSentByMe,
|
||||
required Duration? eta,
|
||||
required int transferCount,
|
||||
}) {
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
fragmentCount: envelope.total,
|
||||
@@ -527,16 +585,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (error != null) return error;
|
||||
if (isRequesting) {
|
||||
final etaLabel = _formatEta(eta);
|
||||
return '📥 Loading… $received/$total · $etaLabel · $txEstimateLabel';
|
||||
final actionLabel = isPartialRequest
|
||||
? '📥 Fetching missing fragments…'
|
||||
: '📥 Loading…';
|
||||
return '$actionLabel $received/$total · $etaLabel · $txEstimateLabel';
|
||||
}
|
||||
if (isReceivingData) {
|
||||
final etaLabel = _formatEta(eta);
|
||||
return '📥 Receiving… $received/$total · $etaLabel · $txEstimateLabel';
|
||||
}
|
||||
if (isComplete) {
|
||||
final base =
|
||||
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
|
||||
return isSentByMe
|
||||
? '$base · ${envelope.total} seg · $txEstimateLabel'
|
||||
? '$base · ${envelope.total} seg · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '$base · $txEstimateLabel';
|
||||
}
|
||||
return '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel';
|
||||
return isSentByMe
|
||||
? '🖼️ ${envelope.width}×${envelope.height} · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel';
|
||||
}
|
||||
|
||||
static String _formatTransmitEstimate(Duration value) {
|
||||
@@ -554,6 +621,22 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
return 'ETA ~${minutes}m ${seconds}s';
|
||||
}
|
||||
|
||||
static String _formatTransferCount(int transferCount) {
|
||||
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
bool _hasRecentInboundActivity({
|
||||
required DateTime? lastReceivedAt,
|
||||
required int received,
|
||||
required int total,
|
||||
}) {
|
||||
if (lastReceivedAt == null || received <= 0 || received >= total) {
|
||||
return false;
|
||||
}
|
||||
return DateTime.now().difference(lastReceivedAt) <=
|
||||
_recentInboundActivityWindow;
|
||||
}
|
||||
|
||||
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
|
||||
showGeneralDialog<void>(
|
||||
context: context,
|
||||
@@ -600,3 +683,67 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PacketBlockProgress extends StatelessWidget {
|
||||
final List<bool> presence;
|
||||
final Color activeColor;
|
||||
final bool highlightMissing;
|
||||
|
||||
const _PacketBlockProgress({
|
||||
required this.presence,
|
||||
required this.activeColor,
|
||||
this.highlightMissing = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (presence.isEmpty) {
|
||||
return const SizedBox(width: 96, height: 12);
|
||||
}
|
||||
|
||||
final bucketCount = presence.length <= 24 ? presence.length : 24;
|
||||
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
|
||||
final start = (bucketIndex * presence.length) ~/ bucketCount;
|
||||
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
|
||||
final safeEnd = end <= start ? start + 1 : end;
|
||||
final slice = presence.sublist(start, safeEnd);
|
||||
final received = slice.where((value) => value).length;
|
||||
return slice.isEmpty ? 0.0 : received / slice.length;
|
||||
});
|
||||
final missingColor = highlightMissing
|
||||
? Colors.amberAccent
|
||||
: Colors.white.withValues(alpha: 0.14);
|
||||
|
||||
return SizedBox(
|
||||
width: 120,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final fill in bucketFill)
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 12,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: fill > 0
|
||||
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.45 : 0.14,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(
|
||||
color: fill > 0
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.7 : 0.18,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart';
|
||||
import '../../utils/location_formats.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import '../../models/message_transfer_details.dart';
|
||||
import 'voice_message_bubble.dart';
|
||||
import 'image_message_bubble.dart';
|
||||
import 'tictactoe_message_bubble.dart';
|
||||
@@ -119,20 +120,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Create new message ID for retry
|
||||
final retryMessageId = '${failedMessage.id}_retry';
|
||||
|
||||
// Create retry message
|
||||
final retryMessage = failedMessage.copyWith(
|
||||
id: retryMessageId,
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
);
|
||||
|
||||
// Add retry message to provider
|
||||
Contact? roomContact;
|
||||
if (failedMessage.messageType == MessageType.contact) {
|
||||
if (failedMessage.recipientPublicKey == null) {
|
||||
messagesProvider.markMessageFailed(retryMessageId);
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.cannotRetryMissingRecipient,
|
||||
@@ -148,13 +138,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
}).firstOrNull;
|
||||
}
|
||||
|
||||
messagesProvider.addSentMessage(retryMessage, contact: roomContact);
|
||||
|
||||
// Resend the message
|
||||
if (failedMessage.messageType == MessageType.contact) {
|
||||
// Direct message retry (for SAR markers sent to rooms)
|
||||
if (failedMessage.recipientPublicKey == null) {
|
||||
messagesProvider.markMessageFailed(retryMessageId);
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.cannotRetryMissingRecipient,
|
||||
@@ -162,26 +149,39 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resend to the same room
|
||||
final prepared = messagesProvider.prepareMessageForRetry(
|
||||
failedMessage.id,
|
||||
);
|
||||
if (!prepared) {
|
||||
return;
|
||||
}
|
||||
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: failedMessage.recipientPublicKey!,
|
||||
text: failedMessage.text,
|
||||
messageId: retryMessageId,
|
||||
messageId: failedMessage.id,
|
||||
contact: roomContact,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (!sentSuccessfully) {
|
||||
messagesProvider.markMessageFailed(retryMessageId);
|
||||
messagesProvider.markMessageFailed(failedMessage.id);
|
||||
ToastLogger.error(context, 'Failed to resend message');
|
||||
}
|
||||
} else if (failedMessage.messageType == MessageType.channel) {
|
||||
final prepared = messagesProvider.prepareMessageForRetry(
|
||||
failedMessage.id,
|
||||
);
|
||||
if (!prepared) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Channel message retry
|
||||
await connectionProvider.sendChannelMessage(
|
||||
channelIdx: failedMessage.channelIdx ?? 0,
|
||||
text: failedMessage.text,
|
||||
messageId: retryMessageId,
|
||||
messageId: failedMessage.id,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
@@ -415,9 +415,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
final receptionDetails = messagesProvider.getMessageReceptionDetails(
|
||||
widget.message.id,
|
||||
);
|
||||
final transferDetails = messagesProvider.getMessageTransferDetails(
|
||||
widget.message.id,
|
||||
);
|
||||
|
||||
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
|
||||
final voiceSession = widget.message.voiceId != null
|
||||
? voiceProvider.session(widget.message.voiceId!)
|
||||
: null;
|
||||
@@ -454,16 +456,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
)
|
||||
: legacyVoicePacket != null
|
||||
? estimateVoiceTransmitDuration(
|
||||
mode: legacyVoicePacket.mode,
|
||||
packetCount: legacyVoicePacket.total,
|
||||
durationMs: legacyVoicePacket.durationMs * legacyVoicePacket.total,
|
||||
pathLen: widget.message.pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
)
|
||||
: Duration.zero;
|
||||
|
||||
final senderPrefixHex = widget.message.senderPublicKeyPrefix
|
||||
@@ -540,10 +532,17 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
'Text length: ${widget.message.text.length}',
|
||||
];
|
||||
|
||||
if (transferDetails != null) {
|
||||
rawLines.add('Transfers served: ${transferDetails.totalTransfers}');
|
||||
rawLines.add(
|
||||
'Downloaded by: ${_formatDownloaderSummary(transferDetails)}',
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.message.isVoice) {
|
||||
rawLines.add('--- Voice Technical ---');
|
||||
if (envelope != null) {
|
||||
rawLines.add('Envelope format: VE1 compact');
|
||||
rawLines.add('Envelope format: VE3 compact');
|
||||
rawLines.add(
|
||||
'Voice mode: ${envelope.mode.label} (id=${envelope.mode.id})',
|
||||
);
|
||||
@@ -551,17 +550,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
rawLines.add(
|
||||
'Estimated duration ms (envelope): ${envelope.durationMs}',
|
||||
);
|
||||
rawLines.add('Envelope senderKey6: ${envelope.senderKey6}');
|
||||
rawLines.add('Envelope ts: ${envelope.timestampSec}');
|
||||
rawLines.add('Envelope ver: ${envelope.version}');
|
||||
} else if (legacyVoicePacket != null) {
|
||||
rawLines.add('Envelope format: legacy V packet');
|
||||
rawLines.add(
|
||||
'Legacy segment index/total: ${legacyVoicePacket.index + 1}/${legacyVoicePacket.total}',
|
||||
);
|
||||
rawLines.add(
|
||||
'Legacy codec mode: ${legacyVoicePacket.mode.label} (id=${legacyVoicePacket.mode.id})',
|
||||
);
|
||||
} else {
|
||||
rawLines.add('Envelope format: unknown');
|
||||
}
|
||||
@@ -604,8 +593,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
rawLines.add(
|
||||
'Estimated image tx: ~${imageTxEstimate.inSeconds}s (current radio)',
|
||||
);
|
||||
rawLines.add('Envelope senderKey6: ${imageEnvelope.senderKey6}');
|
||||
rawLines.add('Envelope ts: ${imageEnvelope.timestampSec}');
|
||||
rawLines.add('Envelope ver: ${imageEnvelope.version}');
|
||||
|
||||
if (imageSession != null) {
|
||||
@@ -916,11 +903,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
_detailRow(
|
||||
context,
|
||||
label: l10n.envelope,
|
||||
value: envelope != null
|
||||
? 'VE1 compact'
|
||||
: legacyVoicePacket != null
|
||||
? 'Legacy V packet'
|
||||
: l10n.unknown,
|
||||
value: envelope != null ? 'VE3 compact' : l10n.unknown,
|
||||
),
|
||||
if (voiceSession != null)
|
||||
_detailRow(
|
||||
@@ -937,6 +920,21 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
? l10n.yes
|
||||
: l10n.no,
|
||||
),
|
||||
if (transferDetails != null)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Transfers',
|
||||
value: '${transferDetails.totalTransfers}',
|
||||
),
|
||||
if (transferDetails != null &&
|
||||
transferDetails.downloaders.isNotEmpty)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Downloaded by',
|
||||
value: _formatDownloaderSummary(
|
||||
transferDetails,
|
||||
),
|
||||
),
|
||||
if (voiceTxEstimate > Duration.zero)
|
||||
_detailRow(
|
||||
context,
|
||||
@@ -988,6 +986,21 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
? l10n.yes
|
||||
: l10n.no,
|
||||
),
|
||||
if (transferDetails != null)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Transfers',
|
||||
value: '${transferDetails.totalTransfers}',
|
||||
),
|
||||
if (transferDetails != null &&
|
||||
transferDetails.downloaders.isNotEmpty)
|
||||
_detailRow(
|
||||
context,
|
||||
label: 'Downloaded by',
|
||||
value: _formatDownloaderSummary(
|
||||
transferDetails,
|
||||
),
|
||||
),
|
||||
if (imageTxEstimate > Duration.zero)
|
||||
_detailRow(
|
||||
context,
|
||||
@@ -1147,6 +1160,20 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDownloaderSummary(MessageTransferDetails transferDetails) {
|
||||
return transferDetails.downloaders.map(_formatDownloaderLabel).join(', ');
|
||||
}
|
||||
|
||||
String _formatDownloaderLabel(MessageTransferDownloader downloader) {
|
||||
final name = downloader.requesterName?.trim();
|
||||
final base = name != null && name.isNotEmpty
|
||||
? '$name (${downloader.requesterKey6})'
|
||||
: downloader.requesterKey6;
|
||||
return downloader.transferCount > 1
|
||||
? '$base ×${downloader.transferCount}'
|
||||
: base;
|
||||
}
|
||||
|
||||
Widget _signalRow(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
|
||||
@@ -65,8 +65,28 @@ Widget buildBubbleMetaFooter(
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
|
||||
|
||||
final items = <Widget>[];
|
||||
final sentEchoLabel = message.isSentMessage && message.echoCount > 0
|
||||
? '${message.echoCount} echo${message.echoCount == 1 ? '' : 'es'}'
|
||||
: null;
|
||||
|
||||
if (!isSarMarker && message.pathLen < 255) {
|
||||
if (!isSarMarker && sentEchoLabel != null) {
|
||||
items.addAll([
|
||||
Icon(Icons.hub_outlined, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
sentEchoLabel,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
Text(
|
||||
' • ',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
]);
|
||||
} else if (!isSarMarker && message.pathLen < 255) {
|
||||
items.addAll([
|
||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../models/ble_packet_log.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../services/mesh_map_nodes_service.dart';
|
||||
|
||||
class MessageTraceSheet extends StatefulWidget {
|
||||
@@ -30,9 +31,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
|
||||
Future<_TraceResult> _loadTrace() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final nodes = await MeshMapNodesService.fetchNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final packetPath = _extractPathFromPacketLogs(
|
||||
logs: connectionProvider.bleService.packetLogs,
|
||||
message: widget.message,
|
||||
@@ -43,45 +42,27 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
? _toPrefixHex(widget.message.recipientPublicKey)
|
||||
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
|
||||
|
||||
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
|
||||
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
|
||||
|
||||
if (packetPath != null && packetPath.isNotEmpty) {
|
||||
final matched = _matchNodesFromPathHashes(
|
||||
nodes: nodes,
|
||||
pathHashes: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
return _TraceResult(
|
||||
mode: TraceMode.packetPath,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: packetPath,
|
||||
matchedPathNodes: matched,
|
||||
);
|
||||
final localNodes = _localNodesFromContacts(contactsProvider);
|
||||
var trace = _buildTraceResult(
|
||||
nodes: localNodes,
|
||||
packetPath: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
if (_isCompleteTrace(trace, expectedRelayCount: math.max(0, widget.message.pathLen))) {
|
||||
return trace;
|
||||
}
|
||||
|
||||
// Fallback when packet path is unavailable.
|
||||
final inferred = _inferRelaysFromHopCount(
|
||||
nodes: nodes,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
relayCount: math.max(0, widget.message.pathLen),
|
||||
final remoteNodes = await MeshMapNodesService.fetchNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
final matchedPathNodes = <MeshMapNode?>[
|
||||
if (senderNode != null) senderNode,
|
||||
...inferred,
|
||||
if (recipientNode != null) recipientNode,
|
||||
];
|
||||
|
||||
return _TraceResult(
|
||||
mode: TraceMode.hopCountInference,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: const [],
|
||||
matchedPathNodes: matchedPathNodes,
|
||||
trace = _buildTraceResult(
|
||||
nodes: _mergeNodes(localNodes, remoteNodes),
|
||||
packetPath: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
return trace;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -109,12 +90,16 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
}
|
||||
|
||||
final trace = snapshot.data!;
|
||||
final mapPoints = trace.matchedPathNodes
|
||||
.whereType<MeshMapNode>()
|
||||
final routeEntries = _displayRouteEntries(trace);
|
||||
final concretePathNodes = routeEntries
|
||||
.where((entry) => entry.node != null)
|
||||
.map((entry) => entry.node!)
|
||||
.toList();
|
||||
final mapPoints = concretePathNodes
|
||||
.map((n) => LatLng(n.latitude, n.longitude))
|
||||
.toList();
|
||||
final hasMapPath = mapPoints.length >= 2;
|
||||
final relayNodes = _relayNodes(trace.matchedPathNodes);
|
||||
final relayNodes = _relayNodes(trace);
|
||||
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.75,
|
||||
@@ -197,9 +182,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
],
|
||||
),
|
||||
flutter_map.MarkerLayer(
|
||||
markers: trace.matchedPathNodes
|
||||
.whereType<MeshMapNode>()
|
||||
.toList()
|
||||
markers: concretePathNodes
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
@@ -216,10 +199,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
entry.key == 0
|
||||
? Colors.green
|
||||
: (entry.key ==
|
||||
trace.matchedPathNodes
|
||||
.whereType<
|
||||
MeshMapNode
|
||||
>()
|
||||
concretePathNodes
|
||||
.length -
|
||||
1
|
||||
? Colors.red
|
||||
@@ -250,6 +230,48 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Route',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (routeEntries.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'No named nodes could be matched for this trace.',
|
||||
),
|
||||
),
|
||||
...routeEntries.asMap().entries.map(
|
||||
(entry) => ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: entry.key == 0
|
||||
? Colors.green
|
||||
: (entry.key == routeEntries.length - 1
|
||||
? Colors.red
|
||||
: Colors.blue),
|
||||
child: Text(
|
||||
'${entry.key + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(entry.value.label),
|
||||
subtitle: Text(
|
||||
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
@@ -288,12 +310,71 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
List<MeshMapNode> _relayNodes(List<MeshMapNode?> path) {
|
||||
final concrete = path.whereType<MeshMapNode>().toList();
|
||||
List<MeshMapNode> _relayNodes(_TraceResult trace) {
|
||||
final concrete = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
|
||||
if (concrete.isEmpty) return const [];
|
||||
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
if (concrete.length <= 1) return const [];
|
||||
return concrete.sublist(1);
|
||||
}
|
||||
|
||||
if (concrete.length <= 2) return const [];
|
||||
return concrete.sublist(1, concrete.length - 1);
|
||||
}
|
||||
|
||||
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
||||
final pathNodes = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
|
||||
if (pathNodes.isEmpty) {
|
||||
return [
|
||||
if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!),
|
||||
if (trace.recipient != null &&
|
||||
trace.recipient!.publicKey != trace.sender?.publicKey)
|
||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
||||
];
|
||||
}
|
||||
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
|
||||
final hashHex = trace.pathHashes[entry.key]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0');
|
||||
return _RouteDisplayEntry(
|
||||
node: entry.value,
|
||||
label: entry.value?.name ?? 'Unknown',
|
||||
keyLabel: entry.value != null
|
||||
? _prefixKeyLabel(entry.value!.publicKey)
|
||||
: hashHex,
|
||||
);
|
||||
}).toList();
|
||||
final lastKey = pathNodes.last.publicKey;
|
||||
return [
|
||||
...entries,
|
||||
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
|
||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
||||
];
|
||||
}
|
||||
|
||||
final firstKey = pathNodes.first.publicKey;
|
||||
final lastKey = pathNodes.last.publicKey;
|
||||
return [
|
||||
if (trace.sender != null && trace.sender!.publicKey != firstKey)
|
||||
_RouteDisplayEntry.fromNode(trace.sender!),
|
||||
...pathNodes.map(_RouteDisplayEntry.fromNode),
|
||||
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
|
||||
_RouteDisplayEntry.fromNode(trace.recipient!),
|
||||
];
|
||||
}
|
||||
|
||||
String _prefixKeyLabel(String publicKey) =>
|
||||
publicKey.substring(0, math.min(12, publicKey.length));
|
||||
|
||||
String _routeRoleLabel(int index, int total) {
|
||||
if (index == 0) return 'Sender';
|
||||
if (index == total - 1) return 'Recipient';
|
||||
return 'Relay';
|
||||
}
|
||||
|
||||
String? _toPrefixHex(List<int>? key) {
|
||||
if (key == null || key.isEmpty) return null;
|
||||
final take = key.length < 6 ? key.length : 6;
|
||||
@@ -312,6 +393,98 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
return matches.isEmpty ? null : matches.first;
|
||||
}
|
||||
|
||||
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
|
||||
return contactsProvider.contactsWithLocation
|
||||
.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
return MeshMapNode(
|
||||
type: contact.type.index,
|
||||
name: contact.displayName,
|
||||
publicKey: contact.publicKeyHex.toLowerCase(),
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
updatedAtMs: contact.lastAdvert * 1000,
|
||||
);
|
||||
})
|
||||
.whereType<MeshMapNode>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<MeshMapNode> _mergeNodes(
|
||||
List<MeshMapNode> preferred,
|
||||
List<MeshMapNode> fallback,
|
||||
) {
|
||||
final merged = <String, MeshMapNode>{};
|
||||
for (final node in fallback) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
for (final node in preferred) {
|
||||
merged[node.publicKey] = node;
|
||||
}
|
||||
return merged.values.toList();
|
||||
}
|
||||
|
||||
_TraceResult _buildTraceResult({
|
||||
required List<MeshMapNode> nodes,
|
||||
required List<int>? packetPath,
|
||||
required String? senderPrefix,
|
||||
required String? recipientPrefix,
|
||||
}) {
|
||||
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
|
||||
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
|
||||
|
||||
if (packetPath != null && packetPath.isNotEmpty) {
|
||||
final matched = _matchNodesFromPathHashes(
|
||||
nodes: nodes,
|
||||
pathHashes: packetPath,
|
||||
senderPrefix: senderPrefix,
|
||||
recipientPrefix: recipientPrefix,
|
||||
);
|
||||
return _TraceResult(
|
||||
mode: TraceMode.packetPath,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: packetPath,
|
||||
matchedPathNodes: matched,
|
||||
);
|
||||
}
|
||||
|
||||
final inferred = _inferRelaysFromHopCount(
|
||||
nodes: nodes,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
relayCount: math.max(0, widget.message.pathLen),
|
||||
);
|
||||
final matchedPathNodes = <MeshMapNode?>[
|
||||
if (senderNode != null) senderNode,
|
||||
...inferred,
|
||||
if (recipientNode != null) recipientNode,
|
||||
];
|
||||
|
||||
return _TraceResult(
|
||||
mode: TraceMode.hopCountInference,
|
||||
sender: senderNode,
|
||||
recipient: recipientNode,
|
||||
pathHashes: const [],
|
||||
matchedPathNodes: matchedPathNodes,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
|
||||
if (trace.sender == null || trace.recipient == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trace.mode == TraceMode.packetPath) {
|
||||
return trace.matchedPathNodes.length == trace.pathHashes.length &&
|
||||
trace.matchedPathNodes.every((node) => node != null);
|
||||
}
|
||||
|
||||
final concreteCount = trace.matchedPathNodes.whereType<MeshMapNode>().length;
|
||||
return concreteCount >= expectedRelayCount + 2;
|
||||
}
|
||||
|
||||
List<int>? _extractPathFromPacketLogs({
|
||||
required List<BlePacketLog> logs,
|
||||
required Message message,
|
||||
@@ -371,11 +544,6 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
.where((n) => n.publicKey.startsWith(senderPrefix))
|
||||
.toList();
|
||||
if (senderMatches.isNotEmpty) filtered = senderMatches;
|
||||
} else if (i == pathHashes.length - 1 && recipientPrefix != null) {
|
||||
final recipientMatches = filtered
|
||||
.where((n) => n.publicKey.startsWith(recipientPrefix))
|
||||
.toList();
|
||||
if (recipientMatches.isNotEmpty) filtered = recipientMatches;
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||
@@ -461,3 +629,23 @@ class _TraceResult {
|
||||
required this.matchedPathNodes,
|
||||
});
|
||||
}
|
||||
|
||||
class _RouteDisplayEntry {
|
||||
final MeshMapNode? node;
|
||||
final String label;
|
||||
final String? keyLabel;
|
||||
|
||||
const _RouteDisplayEntry({
|
||||
required this.node,
|
||||
required this.label,
|
||||
required this.keyLabel,
|
||||
});
|
||||
|
||||
factory _RouteDisplayEntry.fromNode(MeshMapNode node) {
|
||||
return _RouteDisplayEntry(
|
||||
node: node,
|
||||
label: node.name,
|
||||
keyLabel: node.publicKey.substring(0, math.min(12, node.publicKey.length)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../providers/voice_provider.dart';
|
||||
import '../../utils/transmission_target_resolver.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
@@ -28,7 +30,9 @@ class VoiceMessageBubble extends StatefulWidget {
|
||||
|
||||
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
static const int _maxFetchHops = 3;
|
||||
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
|
||||
bool _isRequesting = false;
|
||||
bool _isPartialRequest = false;
|
||||
bool _autoPlayWhenReady = false;
|
||||
String? _errorText;
|
||||
Timer? _requestTimeoutTimer;
|
||||
@@ -55,6 +59,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
|
||||
return Consumer<VoiceProvider>(
|
||||
builder: (context, voiceProvider, _) {
|
||||
final transferCount = context.select<MessagesProvider, int>(
|
||||
(provider) =>
|
||||
provider.transferCountForSession(voiceSessionId: voiceId),
|
||||
);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final session = voiceProvider.session(voiceId);
|
||||
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||
@@ -63,11 +71,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
);
|
||||
final effectivePathLen = sender != null && sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender != null && sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: widget.message.pathLen;
|
||||
final isPlaying = voiceProvider.isPlaying(voiceId);
|
||||
final isComplete = voiceProvider.isComplete(voiceId);
|
||||
@@ -77,6 +84,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = null;
|
||||
});
|
||||
});
|
||||
@@ -93,9 +101,17 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
final received = session?.receivedCount ?? 0;
|
||||
final total = session?.total ?? envelope?.total ?? 0;
|
||||
final playbackProgress = voiceProvider.playbackProgress(voiceId);
|
||||
final requestProgress = total > 0
|
||||
? (received / total).clamp(0.0, 1.0)
|
||||
: null;
|
||||
final packetPresence =
|
||||
session?.packets.map((packet) => packet != null).toList() ??
|
||||
List<bool>.filled(total, false);
|
||||
final isReceivingData =
|
||||
!_isRequesting &&
|
||||
!isComplete &&
|
||||
_hasRecentInboundActivity(
|
||||
lastReceivedAt: session?.lastPacketAt,
|
||||
received: received,
|
||||
total: total,
|
||||
);
|
||||
final durationSec =
|
||||
session?.estimatedDurationSeconds ??
|
||||
((envelope?.durationMs ?? 0) / 1000.0);
|
||||
@@ -117,32 +133,37 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
||||
final eta = voiceProvider.estimateRemainingTransferTime(voiceId);
|
||||
|
||||
Future<void> handlePrimaryTap() async {
|
||||
if (isPlaying) {
|
||||
await voiceProvider.stop();
|
||||
return;
|
||||
}
|
||||
if (_isRequesting) {
|
||||
_cancelReceive(voiceId);
|
||||
return;
|
||||
}
|
||||
if (isComplete) {
|
||||
await voiceProvider.play(voiceId);
|
||||
return;
|
||||
}
|
||||
if (isReceivingData) {
|
||||
return;
|
||||
}
|
||||
await _requestAndPlayVoice(
|
||||
voiceId,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: effectivePathLen,
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
if (isPlaying) {
|
||||
await voiceProvider.stop();
|
||||
return;
|
||||
}
|
||||
if (_isRequesting) {
|
||||
_cancelReceive(voiceId);
|
||||
return;
|
||||
}
|
||||
if (isComplete) {
|
||||
await voiceProvider.play(voiceId);
|
||||
return;
|
||||
}
|
||||
await _requestAndPlayVoice(
|
||||
voiceId,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: effectivePathLen,
|
||||
);
|
||||
},
|
||||
onTap: handlePrimaryTap,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
width: 48,
|
||||
@@ -156,11 +177,16 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
child: Icon(
|
||||
isPlaying
|
||||
? Icons.stop
|
||||
: (_isRequesting ? Icons.close : Icons.play_arrow),
|
||||
: (_isRequesting
|
||||
? Icons.close
|
||||
: (isReceivingData
|
||||
? Icons.downloading_rounded
|
||||
: Icons.play_arrow)),
|
||||
size: 28,
|
||||
color: widget.isSentByMe
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer
|
||||
.withValues(alpha: isReceivingData ? 0.6 : 1.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -169,14 +195,22 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isPlaying || _isRequesting)
|
||||
if (isPlaying)
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: LinearProgressIndicator(
|
||||
value: isPlaying ? playbackProgress : requestProgress,
|
||||
value: playbackProgress,
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.3),
|
||||
),
|
||||
)
|
||||
else if ((_isRequesting || isReceivingData) && total > 0)
|
||||
_PacketBlockProgress(
|
||||
presence: packetPresence,
|
||||
activeColor: widget.isSentByMe
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.secondary,
|
||||
highlightMissing: _isPartialRequest,
|
||||
)
|
||||
else
|
||||
_WaveformBar(isComplete: isComplete, bars: waveformBars),
|
||||
const SizedBox(height: 4),
|
||||
@@ -189,11 +223,15 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
total: total,
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
isPartialRequest: _isPartialRequest,
|
||||
errorText: _errorText,
|
||||
requestingLabel: AppLocalizations.of(
|
||||
context,
|
||||
)!.requestingVoice,
|
||||
eta: eta,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
transferCount: transferCount,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -221,6 +259,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
if (_isRequesting) return;
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = true;
|
||||
_errorText = null;
|
||||
});
|
||||
@@ -236,7 +275,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
@@ -287,7 +325,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
@@ -329,9 +366,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
}
|
||||
}
|
||||
|
||||
if (sender.outPathLen >= 2) {
|
||||
if (!sender.routeSupportsLegacyRawTransport) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
'Cannot fetch voice',
|
||||
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender.routeHopCount >= 2) {
|
||||
_showToast(
|
||||
'Voice fetch over ${sender.outPathLen} hops may take a while.',
|
||||
'Voice fetch over ${sender.routeHopCount} hops may take a while.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,29 +403,30 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
);
|
||||
final isPartialResume =
|
||||
missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets;
|
||||
if (_isPartialRequest != isPartialResume && mounted) {
|
||||
setState(() {
|
||||
_isPartialRequest = isPartialResume;
|
||||
});
|
||||
}
|
||||
final request = isPartialResume
|
||||
? VoiceFetchRequest(
|
||||
sessionId: sessionId,
|
||||
want: 'missing',
|
||||
missingIndices: missing,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
version: 2,
|
||||
)
|
||||
: VoiceFetchRequest(
|
||||
sessionId: sessionId,
|
||||
requesterKey6: requesterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
version: 2,
|
||||
);
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
'🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.outPathLen}',
|
||||
'🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
|
||||
);
|
||||
await connectionProvider.sendRawVoicePacket(
|
||||
contactPath: sender.outPath,
|
||||
contactPathLen: sender.outPathLen,
|
||||
contactPathLen: sender.routeSignedPathLen,
|
||||
payload: request.encodeBinary(),
|
||||
);
|
||||
} catch (_) {
|
||||
@@ -388,8 +435,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
}
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final effectivePathLen = sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
final effectivePathLen = sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: pathLen;
|
||||
final estimatedDurationMs =
|
||||
envelope != null &&
|
||||
@@ -433,6 +480,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
_showToast(AppLocalizations.of(context)!.voiceUnavailable);
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
_errorText = AppLocalizations.of(context)!.voiceUnavailable;
|
||||
});
|
||||
@@ -442,6 +490,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
});
|
||||
}
|
||||
@@ -453,6 +502,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
_showToast('Voice receive canceled');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
_errorText = 'Voice receive canceled';
|
||||
});
|
||||
@@ -497,19 +547,33 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
required int total,
|
||||
required bool isComplete,
|
||||
required bool isRequesting,
|
||||
required bool isReceivingData,
|
||||
required bool isPartialRequest,
|
||||
required String? errorText,
|
||||
required String requestingLabel,
|
||||
required Duration? eta,
|
||||
required bool isSentByMe,
|
||||
required int transferCount,
|
||||
}) {
|
||||
if (errorText != null) return errorText;
|
||||
final progress = total > 0 ? ' ($received/$total)' : '';
|
||||
if (isRequesting) {
|
||||
return '$requestingLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||
final actionLabel = isPartialRequest
|
||||
? 'Fetching missing voice fragments'
|
||||
: requestingLabel;
|
||||
return '$actionLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||
}
|
||||
if (isReceivingData) {
|
||||
return 'Receiving voice$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||
}
|
||||
if (!isComplete && total > 0) {
|
||||
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
||||
return isSentByMe
|
||||
? '🎙️ $durationLabel · $modeLabel$progress · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
||||
}
|
||||
return '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
|
||||
return isSentByMe
|
||||
? '🎙️ $durationLabel · $modeLabel · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
|
||||
}
|
||||
|
||||
List<double> _resolveWaveformBars({
|
||||
@@ -592,6 +656,86 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
final seconds = eta.inSeconds % 60;
|
||||
return 'ETA ~${minutes}m ${seconds}s';
|
||||
}
|
||||
|
||||
static String _formatTransferCount(int transferCount) {
|
||||
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
|
||||
}
|
||||
|
||||
bool _hasRecentInboundActivity({
|
||||
required DateTime? lastReceivedAt,
|
||||
required int received,
|
||||
required int total,
|
||||
}) {
|
||||
if (lastReceivedAt == null || received <= 0 || received >= total) {
|
||||
return false;
|
||||
}
|
||||
return DateTime.now().difference(lastReceivedAt) <=
|
||||
_recentInboundActivityWindow;
|
||||
}
|
||||
}
|
||||
|
||||
class _PacketBlockProgress extends StatelessWidget {
|
||||
final List<bool> presence;
|
||||
final Color activeColor;
|
||||
final bool highlightMissing;
|
||||
|
||||
const _PacketBlockProgress({
|
||||
required this.presence,
|
||||
required this.activeColor,
|
||||
this.highlightMissing = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (presence.isEmpty) {
|
||||
return const SizedBox(width: 100, height: 16);
|
||||
}
|
||||
|
||||
final bucketCount = presence.length <= 20 ? presence.length : 20;
|
||||
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
|
||||
final start = (bucketIndex * presence.length) ~/ bucketCount;
|
||||
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
|
||||
final safeEnd = end <= start ? start + 1 : end;
|
||||
final slice = presence.sublist(start, safeEnd);
|
||||
final received = slice.where((value) => value).length;
|
||||
return slice.isEmpty ? 0.0 : received / slice.length;
|
||||
});
|
||||
final missingColor = highlightMissing
|
||||
? Colors.amberAccent
|
||||
: Colors.white.withValues(alpha: 0.14);
|
||||
|
||||
return SizedBox(
|
||||
width: 100,
|
||||
height: 16,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final fill in bucketFill)
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: fill > 0
|
||||
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.45 : 0.14,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(
|
||||
color: fill > 0
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: missingColor.withValues(
|
||||
alpha: highlightMissing ? 0.7 : 0.18,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Voice waveform rendered as a row of bars.
|
||||
|
||||
Reference in New Issue
Block a user