Add swarm mode transport doc

This commit is contained in:
Janez T
2026-03-07 14:13:54 +01:00
parent 4e76898c8d
commit 0e4f727e26
43 changed files with 3831 additions and 1078 deletions

View 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'),
),
],
);
}
}

View File

@@ -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),

View File

@@ -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,
),
),
),
),
],
),
);
}
}

View File

@@ -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,

View File

@@ -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),

View File

@@ -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)),
);
}
}

View File

@@ -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.