mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-10-09 19:55:05 +00:00
chore: Initial commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
472
lib/widgets/messages/custom_map_sar_update_sheet.dart
Normal file
472
lib/widgets/messages/custom_map_sar_update_sheet.dart
Normal file
@@ -0,0 +1,472 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/sar_template.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../services/sar_template_service.dart';
|
||||
import 'sar_update_sheet.dart' show TemplateChip;
|
||||
|
||||
class CustomMapSarUpdateSheet extends StatefulWidget {
|
||||
final String mapName;
|
||||
final String mapId;
|
||||
final String pointLabel;
|
||||
final Future<void> Function(
|
||||
String emoji,
|
||||
String name,
|
||||
Uint8List? roomPublicKey,
|
||||
bool sendToChannel,
|
||||
bool sendToAllContacts,
|
||||
int colorIndex,
|
||||
)
|
||||
onSend;
|
||||
|
||||
const CustomMapSarUpdateSheet({
|
||||
super.key,
|
||||
required this.mapName,
|
||||
required this.mapId,
|
||||
required this.pointLabel,
|
||||
required this.onSend,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomMapSarUpdateSheet> createState() =>
|
||||
_CustomMapSarUpdateSheetState();
|
||||
}
|
||||
|
||||
class _CustomMapSarUpdateSheetState extends State<CustomMapSarUpdateSheet> {
|
||||
final SarTemplateService _templateService = SarTemplateService();
|
||||
final TextEditingController _notesController = TextEditingController();
|
||||
|
||||
List<SarTemplate> _templates = [];
|
||||
SarTemplate? _selectedTemplate;
|
||||
Contact? _selectedContact;
|
||||
bool _sendToAllContacts = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeTemplates();
|
||||
_setDefaultDestination();
|
||||
}
|
||||
|
||||
Future<void> _initializeTemplates() async {
|
||||
if (!_templateService.isInitialized) {
|
||||
await _templateService.initialize();
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_templates = _templateService.templates;
|
||||
if (_templates.isNotEmpty) {
|
||||
_selectedTemplate = _templates.first;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _setDefaultDestination() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final roomsAndChannels = contactsProvider.roomsAndChannels;
|
||||
final teamContacts = contactsProvider.chatContacts;
|
||||
|
||||
if (teamContacts.length > 1) {
|
||||
setState(() {
|
||||
_sendToAllContacts = true;
|
||||
_selectedContact = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (teamContacts.length == 1) {
|
||||
setState(() {
|
||||
_sendToAllContacts = false;
|
||||
_selectedContact = teamContacts.first;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (roomsAndChannels.any((c) => c.isRoom)) {
|
||||
setState(() {
|
||||
_sendToAllContacts = false;
|
||||
_selectedContact = roomsAndChannels.firstWhere((c) => c.isRoom);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (roomsAndChannels.isNotEmpty) {
|
||||
setState(() {
|
||||
_sendToAllContacts = false;
|
||||
_selectedContact = roomsAndChannels.first;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
||||
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return AnimatedPadding(
|
||||
padding: EdgeInsets.only(bottom: keyboardHeight),
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.88,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.sendSarMarker,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.customCaveMapPoint,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 80 + bottomSafeArea,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.markerType,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._templates.map((template) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: TemplateChip(
|
||||
template: template,
|
||||
isSelected: _selectedTemplate?.id == template.id,
|
||||
onTap: () =>
|
||||
setState(() => _selectedTemplate = template),
|
||||
),
|
||||
);
|
||||
}),
|
||||
SizedBox(height: 24),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.sendTo,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Consumer<ContactsProvider>(
|
||||
builder: (context, contactsProvider, child) {
|
||||
final teamContacts = contactsProvider.chatContacts;
|
||||
final roomsAndChannels =
|
||||
contactsProvider.roomsAndChannels;
|
||||
final destinations = <Contact>[
|
||||
...teamContacts,
|
||||
...roomsAndChannels.where((c) => c.isRoom),
|
||||
...roomsAndChannels.where((c) => c.isChannel),
|
||||
];
|
||||
|
||||
if (destinations.isEmpty && teamContacts.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.red.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.noDestinationsAvailableLabel,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _sendToAllContacts
|
||||
? 'all_contacts'
|
||||
: _selectedContact?.publicKeyHex,
|
||||
hint: Text(
|
||||
AppLocalizations.of(context)!.selectDestination,
|
||||
),
|
||||
dropdownColor:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
isExpanded: true,
|
||||
items: [
|
||||
if (teamContacts.isNotEmpty)
|
||||
DropdownMenuItem<String>(
|
||||
value: 'all_contacts',
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.group, size: 18),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.allTeamContacts,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...destinations.map((contact) {
|
||||
final icon = contact.isChat
|
||||
? Icons.person
|
||||
: contact.isRoom
|
||||
? Icons.storage
|
||||
: Icons.public;
|
||||
return DropdownMenuItem<String>(
|
||||
value: contact.publicKeyHex,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
contact.getLocalizedDisplayName(
|
||||
context,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value == 'all_contacts') {
|
||||
_sendToAllContacts = true;
|
||||
_selectedContact = null;
|
||||
} else {
|
||||
_sendToAllContacts = false;
|
||||
_selectedContact = destinations.firstWhere(
|
||||
(c) => c.publicKeyHex == value,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.mapPoint,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.map_outlined,
|
||||
size: 20,
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.mapName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.pointLabel,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Map ID: ${widget.mapId}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Additional notes (optional)',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(
|
||||
context,
|
||||
)!.addAdditionalDetails,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomSafeArea),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: colorScheme.outline.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () async {
|
||||
if (_selectedTemplate == null ||
|
||||
(!_sendToAllContacts && _selectedContact == null)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.selectMarkerTypeAndDestination,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await widget.onSend(
|
||||
_selectedTemplate!.emoji,
|
||||
_notesController.text.trim().isEmpty
|
||||
? _selectedTemplate!.name
|
||||
: _notesController.text.trim(),
|
||||
_selectedContact?.isChannel == true
|
||||
? null
|
||||
: _selectedContact?.publicKey,
|
||||
_selectedContact?.isChannel == true,
|
||||
_sendToAllContacts,
|
||||
_templates.indexOf(_selectedTemplate!),
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.send),
|
||||
label: Text(AppLocalizations.of(context)!.send),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
759
lib/widgets/messages/image_message_bubble.dart
Normal file
759
lib/widgets/messages/image_message_bubble.dart
Normal file
@@ -0,0 +1,759 @@
|
||||
import 'dart:async';
|
||||
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 '../../l10n/app_localizations.dart';
|
||||
import '../../utils/transmission_target_resolver.dart';
|
||||
import 'transfer_timeout.dart';
|
||||
|
||||
/// A message bubble that shows a received or sent image.
|
||||
///
|
||||
/// On first render the image is not yet fetched (only the IE2 envelope is
|
||||
/// known). The user taps the thumbnail placeholder → IR2 fetch request is
|
||||
/// sent → binary fragments stream in → bubble rebuilds with the full image.
|
||||
class ImageMessageBubble extends StatefulWidget {
|
||||
final Message message;
|
||||
final bool isSentByMe;
|
||||
|
||||
const ImageMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isSentByMe,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImageMessageBubble> createState() => _ImageMessageBubbleState();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_requestTimeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radioBw = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioBw,
|
||||
);
|
||||
final radioSf = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioSf,
|
||||
);
|
||||
final radioCr = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioCr,
|
||||
);
|
||||
final envelope = ImageEnvelope.tryParse(widget.message.text);
|
||||
if (envelope == null) return const SizedBox.shrink();
|
||||
|
||||
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(
|
||||
contactsProvider: contactsProvider,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderName: widget.message.senderName,
|
||||
);
|
||||
final effectivePathLen = sender != null && sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: widget.message.pathLen;
|
||||
final isComplete = imageProvider.isComplete(envelope.sessionId);
|
||||
final eta = imageProvider.estimateRemainingTransferTime(
|
||||
envelope.sessionId,
|
||||
);
|
||||
|
||||
if (_isRequesting && isComplete) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
? () => _showFullScreen(context, imageBytes!)
|
||||
: null,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 256),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Image area: 256×256 placeholder or actual image
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _buildImageArea(
|
||||
context,
|
||||
imageBytes: imageBytes,
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
received: received,
|
||||
total: total,
|
||||
fragmentPresence: fragmentPresence,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: effectivePathLen,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Status line
|
||||
Text(
|
||||
_statusText(
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
isPartialRequest: _isPartialRequest,
|
||||
received: received,
|
||||
total: total,
|
||||
envelope: envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
error: _errorText,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
eta: eta,
|
||||
pathLen: effectivePathLen,
|
||||
transferCount: transferCount,
|
||||
fetchingMissingLabel: AppLocalizations.of(
|
||||
context,
|
||||
)!.fetchingMissingImageFragments,
|
||||
loadingLabel: AppLocalizations.of(context)!.loadingImage,
|
||||
receivingLabel: AppLocalizations.of(
|
||||
context,
|
||||
)!.receivingImage,
|
||||
tapToLoadLabel: AppLocalizations.of(context)!.tapToLoad,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageArea(
|
||||
BuildContext context, {
|
||||
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,
|
||||
required int? radioCr,
|
||||
required int pathLen,
|
||||
}) {
|
||||
if (isComplete && imageBytes != null) {
|
||||
return AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: AvifImage.memory(imageBytes, fit: BoxFit.cover),
|
||||
);
|
||||
}
|
||||
|
||||
// Placeholder with fetch/progress UI.
|
||||
return AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Container(
|
||||
color: Colors.grey.shade800,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isRequesting) ...[
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
onPressed: () => _cancelReceive(envelope.sessionId),
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
color: Colors.white70,
|
||||
tooltip: AppLocalizations.of(context)!.cancelImageReceive,
|
||||
),
|
||||
),
|
||||
] else if (_errorText != null) ...[
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.broken_image, color: Colors.red, size: 36),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () => _requestAndFetch(
|
||||
envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: pathLen,
|
||||
),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(AppLocalizations.of(context)!.retry),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
// Tap-to-load icon.
|
||||
IconButton(
|
||||
onPressed: isReceivingData
|
||||
? null
|
||||
: () => _requestAndFetch(
|
||||
envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: pathLen,
|
||||
),
|
||||
icon: Icon(
|
||||
isReceivingData
|
||||
? Icons.downloading_rounded
|
||||
: Icons.download_rounded,
|
||||
size: 40,
|
||||
),
|
||||
color: Colors.white70,
|
||||
tooltip: isReceivingData
|
||||
? AppLocalizations.of(context)!.imageAlreadyBeingReceived
|
||||
: AppLocalizations.of(context)!.loadImage,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _requestAndFetch(
|
||||
ImageEnvelope envelope, {
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
int pathLen = 0,
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
|
||||
final conn = context.read<ConnectionProvider>();
|
||||
final imageProvider = context.read<ip.ImageProvider>();
|
||||
imageProvider.resumeIncomingSession(envelope.sessionId);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final appProvider = context.read<AppProvider>();
|
||||
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: conn.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchImage,
|
||||
AppLocalizations.of(context)!.senderContactUnknown,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchImage,
|
||||
AppLocalizations.of(context)!.senderRouteUnknown,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchImage,
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unreachable) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchImage,
|
||||
AppLocalizations.of(context)!.senderRouteNoPathResponse,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = resolution.target!;
|
||||
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
await conn.getContacts();
|
||||
if (!mounted) return;
|
||||
resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: conn.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchImage,
|
||||
AppLocalizations.of(context)!.senderContactUnknown,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchImage,
|
||||
AppLocalizations.of(context)!.senderRouteUnknown,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchImage,
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
sender = resolution.target!;
|
||||
routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchImage,
|
||||
AppLocalizations.of(context)!.senderRouteNoRawResponse,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sender.routeHopCount >= 2) {
|
||||
_showToast(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.imageFetchOverHops('${sender.routeHopCount}'),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() => _errorText = null);
|
||||
final deviceKey = conn.deviceInfo.publicKey;
|
||||
if (deviceKey == null || deviceKey.length < 6) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchImage,
|
||||
AppLocalizations.of(context)!.deviceKeyUnavailable,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final requesterKey6 = deviceKey
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
|
||||
// If we already have some fragments, request only what's missing.
|
||||
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,
|
||||
)
|
||||
: ImageFetchRequest(
|
||||
sessionId: envelope.sessionId,
|
||||
requesterKey6: requesterKey6,
|
||||
);
|
||||
|
||||
final payload = request.encodeBinary();
|
||||
try {
|
||||
debugPrint(
|
||||
'📷 [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.routeEncodedPathLen,
|
||||
payload: payload,
|
||||
);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
_showToast(AppLocalizations.of(context)!.imageFetchFailedToSendRequest);
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = AppLocalizations.of(context)!.imageUnavailable;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final effectivePathLen = sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: pathLen;
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
|
||||
sizeBytes: missing.isEmpty
|
||||
? envelope.sizeBytes
|
||||
: (envelope.sizeBytes * missing.length / envelope.total).round(),
|
||||
pathLen: effectivePathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
_requestTimeoutTimer?.cancel();
|
||||
_requestTimeoutTimer = TransferTimeout.start(
|
||||
txEstimate: txEstimate,
|
||||
onTimeout: () {
|
||||
if (mounted &&
|
||||
_isRequesting &&
|
||||
!imageProvider.isComplete(envelope.sessionId)) {
|
||||
_showToast(AppLocalizations.of(context)!.imageFetchTimedOut);
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = AppLocalizations.of(context)!.imageFetchTimedOut;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showToast(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
|
||||
);
|
||||
}
|
||||
|
||||
void _cancelReceive(String sessionId) {
|
||||
if (!mounted) return;
|
||||
_requestTimeoutTimer?.cancel();
|
||||
context.read<ip.ImageProvider>().cancelIncomingSession(sessionId);
|
||||
_showToast(AppLocalizations.of(context)!.imageReceiveCanceled);
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = AppLocalizations.of(context)!.imageReceiveCanceled;
|
||||
});
|
||||
}
|
||||
|
||||
void _clearRequestState() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showBlockingAlert(String title, String message) async {
|
||||
if (!mounted) return;
|
||||
_showToast('$title: $message');
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _statusText({
|
||||
required bool isComplete,
|
||||
required bool isRequesting,
|
||||
required bool isReceivingData,
|
||||
required bool isPartialRequest,
|
||||
required int received,
|
||||
required int total,
|
||||
required ImageEnvelope envelope,
|
||||
required int pathLen,
|
||||
required int? radioBw,
|
||||
required int? radioSf,
|
||||
required int? radioCr,
|
||||
required String? error,
|
||||
required bool isSentByMe,
|
||||
required Duration? eta,
|
||||
required int transferCount,
|
||||
required String fetchingMissingLabel,
|
||||
required String loadingLabel,
|
||||
required String receivingLabel,
|
||||
required String tapToLoadLabel,
|
||||
}) {
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
fragmentCount: envelope.total,
|
||||
sizeBytes: envelope.sizeBytes,
|
||||
pathLen: pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
|
||||
|
||||
if (error != null) return error;
|
||||
if (isRequesting) {
|
||||
final etaLabel = _formatEta(eta);
|
||||
final actionLabel = isPartialRequest
|
||||
? '📥 $fetchingMissingLabel'
|
||||
: '📥 $loadingLabel';
|
||||
return '$actionLabel $received/$total · $etaLabel · $txEstimateLabel';
|
||||
}
|
||||
if (isReceivingData) {
|
||||
final etaLabel = _formatEta(eta);
|
||||
return '📥 $receivingLabel $received/$total · $etaLabel · $txEstimateLabel';
|
||||
}
|
||||
if (isComplete) {
|
||||
final base =
|
||||
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
|
||||
return isSentByMe
|
||||
? '$base · ${envelope.total} seg · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '$base · $txEstimateLabel';
|
||||
}
|
||||
return isSentByMe
|
||||
? '🖼️ ${envelope.width}×${envelope.height} · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🖼️ $tapToLoadLabel · ${envelope.width}×${envelope.height} · $txEstimateLabel';
|
||||
}
|
||||
|
||||
static String _formatTransmitEstimate(Duration value) {
|
||||
if (value.inSeconds < 60) return '~${value.inSeconds}s tx';
|
||||
final minutes = value.inMinutes;
|
||||
final seconds = value.inSeconds % 60;
|
||||
return '~${minutes}m ${seconds}s tx';
|
||||
}
|
||||
|
||||
static String _formatEta(Duration? eta) {
|
||||
if (eta == null || eta <= Duration.zero) return 'ETA --';
|
||||
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
|
||||
final minutes = eta.inMinutes;
|
||||
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;
|
||||
}
|
||||
|
||||
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
|
||||
showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.black,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: AppLocalizations.of(context)!.closeImagePreview,
|
||||
pageBuilder: (dialogContext, animation, secondaryAnimation) => Material(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: InteractiveViewer(
|
||||
minScale: 1.0,
|
||||
maxScale: 1000.0,
|
||||
clipBehavior: Clip.none,
|
||||
boundaryMargin: const EdgeInsets.all(100000),
|
||||
child: SizedBox.expand(
|
||||
child: AvifImage.memory(imageBytes, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: SafeArea(
|
||||
child: IconButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
color: Colors.white,
|
||||
tooltip: AppLocalizations.of(context)!.close,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 150),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
3096
lib/widgets/messages/message_bubble.dart
Normal file
3096
lib/widgets/messages/message_bubble.dart
Normal file
File diff suppressed because it is too large
Load Diff
231
lib/widgets/messages/message_bubble_header.dart
Normal file
231
lib/widgets/messages/message_bubble_header.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../models/message_route_metadata.dart';
|
||||
import '../../utils/avatar_label_helper.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
|
||||
Widget buildMessageHeaderAvatar(
|
||||
BuildContext context, {
|
||||
required bool isOwnMessage,
|
||||
required bool isChannelMessage,
|
||||
required dynamic senderContact,
|
||||
required String displayName,
|
||||
}) {
|
||||
if (isOwnMessage) {
|
||||
return CircleAvatar(
|
||||
radius: 10.5,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.account_circle,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (senderContact is Contact) {
|
||||
return ContactAvatar(
|
||||
contact: senderContact,
|
||||
radius: 10.5,
|
||||
displayName: displayName,
|
||||
);
|
||||
}
|
||||
|
||||
final background = isChannelMessage
|
||||
? Colors.teal.withValues(alpha: 0.16)
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
final foreground = isChannelMessage
|
||||
? Colors.teal.shade800
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 10.5,
|
||||
backgroundColor: background,
|
||||
child: Text(
|
||||
AvatarLabelHelper.buildLabel(displayName),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: foreground,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBubbleMetaFooter(
|
||||
BuildContext context, {
|
||||
required Message message,
|
||||
required bool isSarMarker,
|
||||
MessageRouteMetadata? routeMetadata,
|
||||
}) {
|
||||
final metaColor = Theme.of(
|
||||
context,
|
||||
).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 && 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) {
|
||||
final routeLabel = _effectiveRouteFooterLabel(message, routeMetadata);
|
||||
if (routeLabel != null) {
|
||||
items.addAll([
|
||||
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
routeLabel,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
Text(
|
||||
' • ',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
items.add(
|
||||
Text(
|
||||
message.getLocalizedTimeAgo(context),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: items),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _effectiveRouteFooterLabel(
|
||||
Message message,
|
||||
MessageRouteMetadata? routeMetadata,
|
||||
) {
|
||||
if (routeMetadata?.mode.name == 'flood') {
|
||||
return 'flood';
|
||||
}
|
||||
|
||||
final effectivePathLen = _effectivePathLen(message, routeMetadata);
|
||||
if (effectivePathLen >= 255) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop';
|
||||
}
|
||||
|
||||
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) {
|
||||
if (routeMetadata?.hopCount != null) {
|
||||
return routeMetadata!.hopCount!;
|
||||
}
|
||||
if (routeMetadata?.mode.name == 'flood') {
|
||||
return 255;
|
||||
}
|
||||
return message.pathLen;
|
||||
}
|
||||
|
||||
Widget buildChannelHeaderPill(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
IconData icon = Icons.campaign_outlined,
|
||||
EdgeInsetsGeometry padding = const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
double iconSize = 11,
|
||||
double iconSpacing = 5,
|
||||
TextStyle? textStyle,
|
||||
}) {
|
||||
final labelColor = Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
|
||||
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
|
||||
),
|
||||
SizedBox(width: iconSpacing),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style:
|
||||
textStyle ??
|
||||
Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: labelColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDirectHeaderCounterpart(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
}) {
|
||||
return buildChannelHeaderPill(
|
||||
context,
|
||||
label: label,
|
||||
icon: Icons.alternate_email,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
iconSize: 10,
|
||||
iconSpacing: 4,
|
||||
textStyle: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontSize: 10,
|
||||
height: 1.0,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.82),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
386
lib/widgets/messages/message_bubble_signal.dart
Normal file
386
lib/widgets/messages/message_bubble_signal.dart
Normal file
@@ -0,0 +1,386 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../models/message_route_metadata.dart';
|
||||
import '../../models/path_selection.dart';
|
||||
import '../../models/message_reception_details.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../utils/link_quality.dart';
|
||||
|
||||
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
|
||||
switch (status) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
return Icons.schedule;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return Icons.done;
|
||||
case MessageDeliveryStatus.delivered:
|
||||
return Icons.done_all;
|
||||
case MessageDeliveryStatus.failed:
|
||||
return Icons.error_outline;
|
||||
case MessageDeliveryStatus.received:
|
||||
return Icons.inbox;
|
||||
}
|
||||
}
|
||||
|
||||
Color getDeliveryStatusColor(MessageDeliveryStatus status) {
|
||||
switch (status) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
return Colors.orange;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return Colors.blue;
|
||||
case MessageDeliveryStatus.delivered:
|
||||
return Colors.green;
|
||||
case MessageDeliveryStatus.failed:
|
||||
return Colors.red;
|
||||
case MessageDeliveryStatus.received:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildChannelEchoStatus(BuildContext context, Message message) {
|
||||
final hasEcho = message.echoCount > 0;
|
||||
|
||||
if (!hasEcho) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final statusColor = getDeliveryStatusColor(message.deliveryStatus);
|
||||
final rssi = message.lastEchoRssiDbm;
|
||||
final snr = message.lastEchoSnrRaw != null
|
||||
? message.lastEchoSnrRaw!.toSigned(8) / 4.0
|
||||
: null;
|
||||
final quality = linkQualityLabel(rssi, snr);
|
||||
final qualityColor = linkQualityColor(quality);
|
||||
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.hub_outlined,
|
||||
label: 'x${message.echoCount}',
|
||||
color: statusColor,
|
||||
),
|
||||
if (message.expectedAckTag != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.tag,
|
||||
label:
|
||||
'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}',
|
||||
color: Colors.indigo,
|
||||
),
|
||||
_techChip(context, icon: Icons.bolt, label: quality, color: qualityColor),
|
||||
if (message.lastEchoRssiDbm != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.network_cell,
|
||||
label: message.lastEchoRssiDbm!.toString(),
|
||||
filled: rssiScore(message.lastEchoRssiDbm!),
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
if (message.lastEchoSnrRaw != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.graphic_eq,
|
||||
label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(1),
|
||||
filled: snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0),
|
||||
color: Colors.teal,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool shouldShowSentChannelStats(Message message) {
|
||||
if (!message.isSentMessage || !message.isChannelMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final hasSignalData =
|
||||
message.echoCount > 0 ||
|
||||
message.lastEchoRssiDbm != null ||
|
||||
message.lastEchoSnrRaw != null ||
|
||||
message.expectedAckTag != null;
|
||||
return hasSignalData;
|
||||
}
|
||||
|
||||
Widget buildReceivedSignalStatus(
|
||||
BuildContext context,
|
||||
Message message, {
|
||||
MessageReceptionDetails? receptionDetails,
|
||||
required int? rssiDbm,
|
||||
required double? snrDb,
|
||||
}) {
|
||||
final hopLabel = hopDisplayLabel(context, message);
|
||||
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.alt_route,
|
||||
label: hopLabel,
|
||||
color: Colors.indigo,
|
||||
),
|
||||
if (receptionDetails?.senderToReceiptMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.schedule,
|
||||
label: _formatMs(receptionDetails!.senderToReceiptMs!),
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
if (receptionDetails?.estimatedTransmitMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.timelapse,
|
||||
label: '~${_formatMs(receptionDetails!.estimatedTransmitMs!)} tx',
|
||||
color: Colors.blue,
|
||||
),
|
||||
if (receptionDetails?.postTransmitDelayMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.hourglass_bottom,
|
||||
label: '+${_formatMs(receptionDetails!.postTransmitDelayMs!)} lag',
|
||||
color: Colors.orange,
|
||||
),
|
||||
if (receptionDetails?.pathBytesHex != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.route,
|
||||
label: receptionDetails!.pathBytesHex!,
|
||||
color: Colors.brown,
|
||||
),
|
||||
if (rssiDbm != null || snrDb != null) ...[
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.bolt,
|
||||
label: linkQualityLabel(rssiDbm, snrDb),
|
||||
color: linkQualityColor(linkQualityLabel(rssiDbm, snrDb)),
|
||||
),
|
||||
if (rssiDbm != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.network_cell,
|
||||
label: '$rssiDbm',
|
||||
filled: rssiScore(rssiDbm),
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
if (snrDb != null)
|
||||
_signalCapsule(
|
||||
context,
|
||||
icon: Icons.graphic_eq,
|
||||
label: snrDb.toStringAsFixed(1),
|
||||
filled: snrScore(snrDb),
|
||||
color: Colors.teal,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSentDirectSignalStatus(
|
||||
BuildContext context,
|
||||
Message message, {
|
||||
required int roundTripTimeMs,
|
||||
required Duration txEstimate,
|
||||
}) {
|
||||
final routeMetadata = context
|
||||
.read<MessagesProvider>()
|
||||
.getMessageRouteMetadata(message.id);
|
||||
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
|
||||
estimatedTransmitMs: txEstimate > Duration.zero
|
||||
? txEstimate.inMilliseconds
|
||||
: null,
|
||||
senderToReceiptMs: roundTripTimeMs,
|
||||
);
|
||||
final postTransmitDelayMs = estimatedTransmitMs != null
|
||||
? (roundTripTimeMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
|
||||
: null;
|
||||
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.alt_route,
|
||||
label: hopDisplayLabelForMessage(context, message, routeMetadata),
|
||||
color: Colors.indigo,
|
||||
),
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.schedule,
|
||||
label: _formatMs(roundTripTimeMs),
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
if (estimatedTransmitMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.timelapse,
|
||||
label: '~${_formatMs(estimatedTransmitMs)} tx',
|
||||
color: Colors.blue,
|
||||
),
|
||||
if (postTransmitDelayMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.hourglass_bottom,
|
||||
label: '+${_formatMs(postTransmitDelayMs)} lag',
|
||||
color: Colors.orange,
|
||||
),
|
||||
if (message.retryAttempt > 0)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.refresh,
|
||||
label: 'retry ${message.retryAttempt}/4',
|
||||
color: Colors.redAccent,
|
||||
),
|
||||
if (message.suggestedTimeoutMs != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.timer_outlined,
|
||||
label: 'timeout ${_formatMs(message.suggestedTimeoutMs!)}',
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
if (message.usedFloodFallback)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.waves,
|
||||
label: 'flood route',
|
||||
color: Colors.teal,
|
||||
)
|
||||
else if (message.expectedAckTag != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: Icons.route,
|
||||
label: 'direct ACK',
|
||||
color: Colors.indigo,
|
||||
),
|
||||
if (routeMetadata != null)
|
||||
_techChip(
|
||||
context,
|
||||
icon: routeMetadata.mode == PathSelectionMode.nearestRouter
|
||||
? Icons.router
|
||||
: Icons.alt_route,
|
||||
label: routeMetadata.modeLabel,
|
||||
color: routeMetadata.mode == PathSelectionMode.nearestRouter
|
||||
? Colors.deepPurple
|
||||
: Colors.indigo,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatMs(int value) {
|
||||
if (value >= 60000) {
|
||||
final minutes = value ~/ 60000;
|
||||
final seconds = (value % 60000) ~/ 1000;
|
||||
return '${minutes}m ${seconds}s';
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)}s';
|
||||
}
|
||||
return '${value}ms';
|
||||
}
|
||||
|
||||
String hopDisplayLabel(BuildContext context, Message message) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (message.pathLen == 0) return l10n.direct;
|
||||
if (message.pathLen >= 255 && message.isContactMessage) return l10n.direct;
|
||||
if (message.pathLen >= 255) return l10n.unknown;
|
||||
return l10n.hopCount(message.pathLen);
|
||||
}
|
||||
|
||||
String hopDisplayLabelForMessage(
|
||||
BuildContext context,
|
||||
Message message,
|
||||
MessageRouteMetadata? routeMetadata,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen;
|
||||
if (effectivePathLen == 0) return l10n.direct;
|
||||
if (effectivePathLen >= 255 && message.isContactMessage) return l10n.direct;
|
||||
if (effectivePathLen >= 255) return l10n.unknown;
|
||||
return l10n.hopCount(effectivePathLen);
|
||||
}
|
||||
|
||||
Widget _techChip(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 10, color: color),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _signalCapsule(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required int filled,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 10, color: color),
|
||||
const SizedBox(width: 2),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(5, (i) {
|
||||
final active = i < filled;
|
||||
return Container(
|
||||
width: 3,
|
||||
height: (4 + i).toDouble(),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 0.5),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? color : color.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
668
lib/widgets/messages/messages_composer.dart
Normal file
668
lib/widgets/messages/messages_composer.dart
Normal file
@@ -0,0 +1,668 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../common/contact_avatar.dart';
|
||||
|
||||
class MessagesComposer extends StatelessWidget {
|
||||
final TextEditingController textController;
|
||||
final FocusNode focusNode;
|
||||
final TextInputFormatter messageByteLimiter;
|
||||
final int messageByteCount;
|
||||
final int maxMessageBytes;
|
||||
final bool isRecording;
|
||||
final bool isSendingVoice;
|
||||
final bool voiceSupported;
|
||||
final double bottomPadding;
|
||||
final String destinationLabel;
|
||||
final Widget destinationAvatar;
|
||||
final bool destinationLocked;
|
||||
final List<Contact> mentionSuggestions;
|
||||
final String mentionQuery;
|
||||
final ValueChanged<Contact> onMentionSelected;
|
||||
final VoidCallback onShowComposerActions;
|
||||
final VoidCallback onShowRecipientSelector;
|
||||
final Future<void> Function() onStartVoiceRecording;
|
||||
final Future<void> Function() onStopAndSendVoice;
|
||||
final Future<void> Function() onCancelVoiceRecording;
|
||||
final Future<void> Function() onSendMessage;
|
||||
final VoidCallback? onLongPressSend;
|
||||
final String? regionScopeName;
|
||||
final VoidCallback? onRegionScopeTap;
|
||||
|
||||
const MessagesComposer({
|
||||
super.key,
|
||||
required this.textController,
|
||||
required this.focusNode,
|
||||
required this.messageByteLimiter,
|
||||
required this.messageByteCount,
|
||||
required this.maxMessageBytes,
|
||||
required this.isRecording,
|
||||
required this.isSendingVoice,
|
||||
required this.voiceSupported,
|
||||
required this.bottomPadding,
|
||||
required this.destinationLabel,
|
||||
required this.destinationAvatar,
|
||||
required this.destinationLocked,
|
||||
required this.mentionSuggestions,
|
||||
required this.mentionQuery,
|
||||
required this.onMentionSelected,
|
||||
required this.onShowComposerActions,
|
||||
required this.onShowRecipientSelector,
|
||||
required this.onStartVoiceRecording,
|
||||
required this.onStopAndSendVoice,
|
||||
required this.onCancelVoiceRecording,
|
||||
required this.onSendMessage,
|
||||
this.onLongPressSend,
|
||||
this.regionScopeName,
|
||||
this.onRegionScopeTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(color: Colors.transparent),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (mentionSuggestions.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
|
||||
child: _MentionSuggestionsCard(
|
||||
suggestions: mentionSuggestions,
|
||||
query: mentionQuery,
|
||||
onSelected: onMentionSelected,
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(10, 4, 10, bottomPadding),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_ComposerActionButton(
|
||||
isRecording: isRecording,
|
||||
onPressed: isRecording
|
||||
? onStopAndSendVoice
|
||||
: onShowComposerActions,
|
||||
),
|
||||
if (isRecording) ...[
|
||||
const SizedBox(width: 6),
|
||||
_CancelRecordingButton(
|
||||
onPressed: onCancelVoiceRecording,
|
||||
),
|
||||
],
|
||||
if (regionScopeName != null &&
|
||||
onRegionScopeTap != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
_RegionScopeChip(
|
||||
name: regionScopeName!,
|
||||
onTap: onRegionScopeTap!,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _DestinationPill(
|
||||
destinationLabel: destinationLabel,
|
||||
destinationAvatar: destinationAvatar,
|
||||
isLocked: destinationLocked,
|
||||
onTap: destinationLocked
|
||||
? null
|
||||
: onShowRecipientSelector,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge([
|
||||
textController,
|
||||
focusNode,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final canSendText =
|
||||
!isRecording &&
|
||||
!isSendingVoice &&
|
||||
textController.text.trim().isNotEmpty;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final semanticsLabel = isRecording
|
||||
? l10n.recordingReleaseToSend
|
||||
: (isSendingVoice
|
||||
? l10n.sendingVoice
|
||||
: voiceSupported
|
||||
? l10n.sendLongPressToRecordVoice
|
||||
: l10n.send);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MessageInput(
|
||||
textController: textController,
|
||||
focusNode: focusNode,
|
||||
messageByteLimiter: messageByteLimiter,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_SendButton(
|
||||
canSendText: canSendText,
|
||||
isRecording: isRecording,
|
||||
isSendingVoice: isSendingVoice,
|
||||
voiceSupported: voiceSupported,
|
||||
semanticsLabel: semanticsLabel,
|
||||
messageByteCount: messageByteCount,
|
||||
maxMessageBytes: maxMessageBytes,
|
||||
onSendMessage: onSendMessage,
|
||||
onLongPressSend: onLongPressSend,
|
||||
onStartVoiceRecording: onStartVoiceRecording,
|
||||
onStopAndSendVoice: onStopAndSendVoice,
|
||||
onCancelVoiceRecording: onCancelVoiceRecording,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MentionSuggestionsCard extends StatelessWidget {
|
||||
final List<Contact> suggestions;
|
||||
final String query;
|
||||
final ValueChanged<Contact> onSelected;
|
||||
|
||||
const _MentionSuggestionsCard({
|
||||
required this.suggestions,
|
||||
required this.query,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visibleCount = suggestions.length.clamp(1, 4);
|
||||
final maxHeight = visibleCount * 54.0;
|
||||
|
||||
return Material(
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.30),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
itemCount: suggestions.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 2),
|
||||
itemBuilder: (context, index) {
|
||||
final contact = suggestions[index];
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => onSelected(contact),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ContactAvatar(contact: contact, radius: 16),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
contact.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ComposerActionButton extends StatelessWidget {
|
||||
final bool isRecording;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _ComposerActionButton({
|
||||
required this.isRecording,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 20),
|
||||
tooltip: isRecording
|
||||
? AppLocalizations.of(context)!.stopRecording
|
||||
: AppLocalizations.of(context)!.moreActions,
|
||||
onPressed: onPressed,
|
||||
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CancelRecordingButton extends StatelessWidget {
|
||||
final Future<void> Function() onPressed;
|
||||
|
||||
const _CancelRecordingButton({required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
tooltip: AppLocalizations.of(context)!.discardRecording,
|
||||
onPressed: onPressed,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DestinationPill extends StatelessWidget {
|
||||
final String destinationLabel;
|
||||
final Widget destinationAvatar;
|
||||
final bool isLocked;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _DestinationPill({
|
||||
required this.destinationLabel,
|
||||
required this.destinationAvatar,
|
||||
required this.isLocked,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final content = Ink(
|
||||
key: ValueKey(
|
||||
isLocked
|
||||
? 'messages_composer_destination_locked'
|
||||
: 'messages_composer_destination_selector',
|
||||
),
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
destinationAvatar,
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
destinationLabel,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isLocked)
|
||||
Icon(
|
||||
Icons.expand_more_rounded,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap == null) {
|
||||
return Material(color: Colors.transparent, child: content);
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onTap,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageInput extends StatelessWidget {
|
||||
final TextEditingController textController;
|
||||
final FocusNode focusNode;
|
||||
final TextInputFormatter messageByteLimiter;
|
||||
|
||||
const _MessageInput({
|
||||
required this.textController,
|
||||
required this.focusNode,
|
||||
required this.messageByteLimiter,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messageFontScale = context.watch<AppProvider>().messageFontScale;
|
||||
const baseFontSize = 15.0;
|
||||
final resolvedFontSize = baseFontSize * messageFontScale;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
constraints: const BoxConstraints(minHeight: 42, maxHeight: 116),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: focusNode.hasFocus
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).dividerColor.withValues(alpha: 0.35),
|
||||
width: focusNode.hasFocus ? 1.4 : 1,
|
||||
),
|
||||
boxShadow: focusNode.hasFocus
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.10),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: TextField(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
keyboardType: TextInputType.multiline,
|
||||
inputFormatters: [messageByteLimiter],
|
||||
style: TextStyle(fontSize: resolvedFontSize),
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(context)!.typeYourMessage,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: resolvedFontSize,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
|
||||
),
|
||||
filled: false,
|
||||
fillColor: Colors.transparent,
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
),
|
||||
textInputAction: TextInputAction.newline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SendButton extends StatelessWidget {
|
||||
final bool canSendText;
|
||||
final bool isRecording;
|
||||
final bool isSendingVoice;
|
||||
final bool voiceSupported;
|
||||
final String semanticsLabel;
|
||||
final int messageByteCount;
|
||||
final int maxMessageBytes;
|
||||
final Future<void> Function() onSendMessage;
|
||||
final VoidCallback? onLongPressSend;
|
||||
final Future<void> Function() onStartVoiceRecording;
|
||||
final Future<void> Function() onStopAndSendVoice;
|
||||
final Future<void> Function() onCancelVoiceRecording;
|
||||
|
||||
const _SendButton({
|
||||
required this.canSendText,
|
||||
required this.isRecording,
|
||||
required this.isSendingVoice,
|
||||
required this.voiceSupported,
|
||||
required this.semanticsLabel,
|
||||
required this.messageByteCount,
|
||||
required this.maxMessageBytes,
|
||||
required this.onSendMessage,
|
||||
this.onLongPressSend,
|
||||
required this.onStartVoiceRecording,
|
||||
required this.onStopAndSendVoice,
|
||||
required this.onCancelVoiceRecording,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: canSendText || (voiceSupported && !isSendingVoice),
|
||||
label: semanticsLabel,
|
||||
onTap: canSendText ? onSendMessage : null,
|
||||
onLongPress: canSendText && onLongPressSend != null
|
||||
? onLongPressSend
|
||||
: (voiceSupported && !isSendingVoice)
|
||||
? () {
|
||||
if (isRecording) {
|
||||
onStopAndSendVoice();
|
||||
return;
|
||||
}
|
||||
onStartVoiceRecording();
|
||||
}
|
||||
: null,
|
||||
child: Tooltip(
|
||||
message: semanticsLabel,
|
||||
excludeFromSemantics: true,
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onTap: canSendText ? onSendMessage : null,
|
||||
onLongPressStart: canSendText && onLongPressSend != null
|
||||
? (_) => onLongPressSend!()
|
||||
: (voiceSupported && !isSendingVoice)
|
||||
? (_) => onStartVoiceRecording()
|
||||
: null,
|
||||
onLongPressEnd: (!canSendText && voiceSupported && isRecording)
|
||||
? (_) => onStopAndSendVoice()
|
||||
: null,
|
||||
// Finger slid off the button: discard the recording instead of
|
||||
// sending it.
|
||||
onLongPressCancel: (!canSendText && voiceSupported && isRecording)
|
||||
? onCancelVoiceRecording
|
||||
: null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: canSendText || isRecording
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: canSendText || isRecording
|
||||
? Colors.transparent
|
||||
: Theme.of(
|
||||
context,
|
||||
).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
boxShadow: canSendText || isRecording
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.22),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: isSendingVoice
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
isRecording ? Icons.mic_rounded : Icons.send_rounded,
|
||||
size: 20,
|
||||
color: canSendText || isRecording
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$messageByteCount/$maxMessageBytes',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: messageByteCount > maxMessageBytes * 0.9
|
||||
? Colors.orange.shade800
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RegionScopeChip extends StatelessWidget {
|
||||
final String name;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _RegionScopeChip({required this.name, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 40,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.tertiary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.language_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 80),
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/widgets/messages/messages_content.dart
Normal file
108
lib/widgets/messages/messages_content.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../common/bidirectional_refresh.dart';
|
||||
import '../../widgets/messages/message_bubble.dart';
|
||||
|
||||
class MessagesContent extends StatelessWidget {
|
||||
static const double defaultPadding = 8;
|
||||
|
||||
final List<DisplayMessageEntry> messages;
|
||||
final ScrollController scrollController;
|
||||
final String? highlightedMessageId;
|
||||
final double bottomContentPadding;
|
||||
final Future<void> Function() onRefresh;
|
||||
final VoidCallback? onNavigateToMap;
|
||||
final ValueChanged<Message>? onMessageTap;
|
||||
final ValueChanged<Message>? onReplyToMessage;
|
||||
|
||||
const MessagesContent({
|
||||
super.key,
|
||||
required this.messages,
|
||||
required this.scrollController,
|
||||
required this.highlightedMessageId,
|
||||
this.bottomContentPadding = 0,
|
||||
required this.onRefresh,
|
||||
this.onNavigateToMap,
|
||||
this.onMessageTap,
|
||||
this.onReplyToMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const scrollPhysics = BouncingScrollPhysics(
|
||||
parent: AlwaysScrollableScrollPhysics(),
|
||||
);
|
||||
|
||||
return BidirectionalRefresh(
|
||||
onRefresh: onRefresh,
|
||||
child: messages.isEmpty
|
||||
? LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
physics: scrollPhysics,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.message_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noMessagesYet,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.pullDownToSync,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: scrollController,
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
physics: scrollPhysics,
|
||||
reverse: true,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
defaultPadding,
|
||||
defaultPadding,
|
||||
defaultPadding,
|
||||
defaultPadding + bottomContentPadding,
|
||||
),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = messages[index];
|
||||
final message = entry.message;
|
||||
|
||||
return MessageBubble(
|
||||
key: ValueKey(message.id),
|
||||
message: message,
|
||||
receivedCopies: entry.occurrenceCount,
|
||||
isHighlighted: message.id == highlightedMessageId,
|
||||
onNavigateToMap: onNavigateToMap,
|
||||
onTap: onMessageTap == null
|
||||
? null
|
||||
: () => onMessageTap!(message),
|
||||
onReply: onReplyToMessage == null
|
||||
? null
|
||||
: () => onReplyToMessage!(message),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1197
lib/widgets/messages/recipient_selector_sheet.dart
Normal file
1197
lib/widgets/messages/recipient_selector_sheet.dart
Normal file
File diff suppressed because it is too large
Load Diff
1319
lib/widgets/messages/sar_update_sheet.dart
Normal file
1319
lib/widgets/messages/sar_update_sheet.dart
Normal file
File diff suppressed because it is too large
Load Diff
107
lib/widgets/messages/system_message_bubble.dart
Normal file
107
lib/widgets/messages/system_message_bubble.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
|
||||
class SystemMessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
|
||||
const SystemMessageBubble({super.key, required this.message});
|
||||
|
||||
Color _getLevelColor(String? level) {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'success':
|
||||
return Colors.green;
|
||||
case 'warning':
|
||||
return Colors.orange;
|
||||
case 'error':
|
||||
return Colors.red;
|
||||
case 'info':
|
||||
default:
|
||||
return Colors.blue.shade300;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getLevelIcon(String? level) {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'success':
|
||||
return Icons.check_circle_outline;
|
||||
case 'warning':
|
||||
return Icons.warning_amber_outlined;
|
||||
case 'error':
|
||||
return Icons.error_outline;
|
||||
case 'info':
|
||||
default:
|
||||
return Icons.info_outline;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final level = message.senderName ?? 'info';
|
||||
final levelColor = _getLevelColor(level);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: levelColor.withValues(alpha: isDarkMode ? 0.18 : 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: levelColor.withValues(alpha: isDarkMode ? 0.3 : 0.16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(_getLevelIcon(level), size: 16, color: levelColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
level.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: levelColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
message.getLocalizedTimeAgo(context),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
message.text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
height: 1.3,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.color?.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
307
lib/widgets/messages/tictactoe_message_bubble.dart
Normal file
307
lib/widgets/messages/tictactoe_message_bubble.dart
Normal file
@@ -0,0 +1,307 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../utils/tictactoe_message_parser.dart';
|
||||
import '../../utils/toast_logger.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
class TicTacToeMessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
final bool isSentByMe;
|
||||
|
||||
const TicTacToeMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isSentByMe,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final event = TicTacToeMessageParser.tryParse(message.text);
|
||||
if (event == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final conn = context.watch<ConnectionProvider>();
|
||||
final contacts = context.read<ContactsProvider>();
|
||||
final messages = context.watch<MessagesProvider>().messages;
|
||||
final selfKey = conn.deviceInfo.publicKey;
|
||||
if (selfKey == null || selfKey.length < 6) {
|
||||
return Text(AppLocalizations.of(context)!.tictactoeUnavailable);
|
||||
}
|
||||
|
||||
final selfKey6 = _key6Hex(selfKey);
|
||||
final opponent = _resolveOpponentContact(
|
||||
message: message,
|
||||
contactsProvider: contacts,
|
||||
isSentByMe: isSentByMe,
|
||||
);
|
||||
if (opponent == null) {
|
||||
return Text(AppLocalizations.of(context)!.tictactoeOpponentUnknown);
|
||||
}
|
||||
final opponentKey6 = _key6Hex(opponent.publicKey);
|
||||
|
||||
final gameEvents = <TicTacToeEvent>[];
|
||||
TicTacToeEvent? start;
|
||||
for (final m in messages) {
|
||||
if (!m.isContactMessage) continue;
|
||||
final parsed = TicTacToeMessageParser.tryParse(m.text);
|
||||
if (parsed == null || parsed.gameId != event.gameId) continue;
|
||||
if (!_isSameDmThread(
|
||||
message: m,
|
||||
selfKey: selfKey,
|
||||
opponentKey6: opponentKey6,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
if (parsed.type == TicTacToeEventType.start) {
|
||||
start ??= parsed;
|
||||
} else {
|
||||
gameEvents.add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
start ??= event.type == TicTacToeEventType.start ? event : null;
|
||||
if (start == null) {
|
||||
return Text(AppLocalizations.of(context)!.tictactoeWaitingForStart);
|
||||
}
|
||||
|
||||
final xPlayer = start.playerKey6;
|
||||
final oPlayer = xPlayer == selfKey6 ? opponentKey6 : selfKey6;
|
||||
final state = buildTicTacToeState(
|
||||
gameId: event.gameId,
|
||||
xPlayerKey6: xPlayer,
|
||||
oPlayerKey6: oPlayer,
|
||||
events: gameEvents,
|
||||
);
|
||||
|
||||
final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O';
|
||||
final isMyTurn = !state.isFinished && state.nextSymbol == mySymbol;
|
||||
final titleColor = isSentByMe
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurface;
|
||||
final statusColor = isSentByMe
|
||||
? colorScheme.onPrimaryContainer.withValues(alpha: 0.85)
|
||||
: colorScheme.onSurface.withValues(alpha: 0.85);
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 230),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Tic-Tac-Toe · Game ${state.gameId}',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_BoardGrid(
|
||||
board: state.board,
|
||||
enabled: isMyTurn,
|
||||
isSentByMe: isSentByMe,
|
||||
onTapCell: (idx) => _onCellTap(
|
||||
context: context,
|
||||
idx: idx,
|
||||
state: state,
|
||||
selfKey6: selfKey6,
|
||||
opponent: opponent,
|
||||
connectionProvider: conn,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_statusText(
|
||||
l10n: AppLocalizations.of(context)!,
|
||||
state: state,
|
||||
mySymbol: mySymbol,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(color: statusColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCellTap({
|
||||
required BuildContext context,
|
||||
required int idx,
|
||||
required TicTacToeGameState state,
|
||||
required String selfKey6,
|
||||
required Contact opponent,
|
||||
required ConnectionProvider connectionProvider,
|
||||
}) async {
|
||||
if (idx < 0 || idx > 8 || state.board[idx] != null) return;
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.notConnectedToDevice,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
final text = TicTacToeMessageParser.encodeMove(
|
||||
gameId: state.gameId,
|
||||
cell: idx,
|
||||
playerKey6: selfKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
final messageId = '${DateTime.now().millisecondsSinceEpoch}_ttt_move';
|
||||
final senderPublicKeyPrefix = connectionProvider.deviceInfo.publicKey!
|
||||
.sublist(0, 6);
|
||||
|
||||
final sentMessage = Message(
|
||||
id: messageId,
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
text: text,
|
||||
receivedAt: DateTime.now(),
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
recipientPublicKey: opponent.publicKey,
|
||||
);
|
||||
messagesProvider.addSentMessage(sentMessage, contact: opponent);
|
||||
|
||||
final sent = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: opponent.publicKey,
|
||||
text: text,
|
||||
messageId: messageId,
|
||||
contact: opponent,
|
||||
);
|
||||
if (!sent) {
|
||||
messagesProvider.markMessageFailed(messageId);
|
||||
if (!context.mounted) return;
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.failedToSendTicTacToeMove,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static String _statusText({
|
||||
required AppLocalizations l10n,
|
||||
required TicTacToeGameState state,
|
||||
required String mySymbol,
|
||||
}) {
|
||||
if (state.winnerSymbol != null) {
|
||||
return state.winnerSymbol == mySymbol ? l10n.youWon : l10n.opponentWon;
|
||||
}
|
||||
if (state.isDraw) return l10n.gameDraw;
|
||||
return state.nextSymbol == mySymbol ? l10n.yourTurn : l10n.opponentTurn;
|
||||
}
|
||||
|
||||
static String _key6Hex(Uint8List key) => key
|
||||
.sublist(0, math.min(6, key.length))
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('')
|
||||
.toLowerCase();
|
||||
|
||||
static Contact? _resolveOpponentContact({
|
||||
required Message message,
|
||||
required ContactsProvider contactsProvider,
|
||||
required bool isSentByMe,
|
||||
}) {
|
||||
if (isSentByMe && message.recipientPublicKey != null) {
|
||||
return contactsProvider.findContactByKey(message.recipientPublicKey!);
|
||||
}
|
||||
final sender = message.senderPublicKeyPrefix;
|
||||
if (sender == null || sender.length < 6) return null;
|
||||
return contactsProvider.findContactByPrefix(
|
||||
Uint8List.fromList(sender.sublist(0, 6)),
|
||||
);
|
||||
}
|
||||
|
||||
static bool _isSameDmThread({
|
||||
required Message message,
|
||||
required Uint8List selfKey,
|
||||
required String opponentKey6,
|
||||
}) {
|
||||
final isOwn = message.isSentMessage || message.isFromSelf(selfKey);
|
||||
if (isOwn) {
|
||||
final recipient = message.recipientPublicKey;
|
||||
if (recipient == null || recipient.length < 6) return false;
|
||||
return _key6Hex(recipient) == opponentKey6;
|
||||
}
|
||||
final sender = message.senderPublicKeyPrefix;
|
||||
if (sender == null || sender.length < 6) return false;
|
||||
return _key6Hex(sender) == opponentKey6;
|
||||
}
|
||||
}
|
||||
|
||||
class _BoardGrid extends StatelessWidget {
|
||||
final List<String?> board;
|
||||
final bool enabled;
|
||||
final bool isSentByMe;
|
||||
final ValueChanged<int> onTapCell;
|
||||
|
||||
const _BoardGrid({
|
||||
required this.board,
|
||||
required this.enabled,
|
||||
required this.isSentByMe,
|
||||
required this.onTapCell,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final cellBackground = isSentByMe
|
||||
? colorScheme.primaryContainer.withValues(alpha: 0.35)
|
||||
: colorScheme.surface;
|
||||
final cellBorder = isSentByMe
|
||||
? colorScheme.primary.withValues(alpha: 0.45)
|
||||
: colorScheme.outline.withValues(alpha: 0.35);
|
||||
|
||||
return SizedBox(
|
||||
width: 180,
|
||||
height: 180,
|
||||
child: GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 4,
|
||||
mainAxisSpacing: 4,
|
||||
),
|
||||
itemCount: 9,
|
||||
itemBuilder: (context, idx) {
|
||||
final value = board[idx];
|
||||
return InkWell(
|
||||
onTap: enabled && value == null ? () => onTapCell(idx) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: cellBackground,
|
||||
border: Border.all(color: cellBorder),
|
||||
),
|
||||
child: Text(
|
||||
value ?? '',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: value == 'X'
|
||||
? colorScheme.primary
|
||||
: value == 'O'
|
||||
? colorScheme.tertiary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
24
lib/widgets/messages/transfer_timeout.dart
Normal file
24
lib/widgets/messages/transfer_timeout.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Calculates a transfer timeout as 2× the estimated LoRa airtime,
|
||||
/// with a minimum of 30 seconds.
|
||||
///
|
||||
/// Used by [ImageMessageBubble] and [VoiceMessageBubble] to reset the
|
||||
/// "loading" spinner when a transfer stalls, allowing the user to retry.
|
||||
class TransferTimeout {
|
||||
static const Duration _minimum = Duration(seconds: 30);
|
||||
|
||||
/// Start a one-shot timer based on [txEstimate] × 2 (min 30s).
|
||||
///
|
||||
/// [onTimeout] is called on the UI thread when the timer fires.
|
||||
/// Returns the [Timer] so the caller can cancel it (e.g. on dispose or
|
||||
/// when the transfer completes).
|
||||
static Timer start({
|
||||
required Duration txEstimate,
|
||||
required void Function() onTimeout,
|
||||
}) {
|
||||
final timeout = txEstimate * 2;
|
||||
final effective = timeout < _minimum ? _minimum : timeout;
|
||||
return Timer(effective, onTimeout);
|
||||
}
|
||||
}
|
||||
773
lib/widgets/messages/voice_message_bubble.dart
Normal file
773
lib/widgets/messages/voice_message_bubble.dart
Normal file
@@ -0,0 +1,773 @@
|
||||
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';
|
||||
import 'transfer_timeout.dart';
|
||||
|
||||
/// A message bubble that shows a voice recording with play/stop controls.
|
||||
class VoiceMessageBubble extends StatefulWidget {
|
||||
final Message message;
|
||||
final bool isSentByMe;
|
||||
|
||||
const VoiceMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isSentByMe,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_requestTimeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radioBw = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioBw,
|
||||
);
|
||||
final radioSf = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioSf,
|
||||
);
|
||||
final radioCr = context.select<ConnectionProvider, int?>(
|
||||
(p) => p.deviceInfo.radioCr,
|
||||
);
|
||||
final voiceId = widget.message.voiceId;
|
||||
if (voiceId == null) return const SizedBox.shrink();
|
||||
|
||||
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);
|
||||
final sender = TransmissionTargetResolver.resolveLocalTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderName: widget.message.senderName,
|
||||
);
|
||||
final effectivePathLen = sender != null && sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: widget.message.pathLen;
|
||||
final isPlaying = voiceProvider.isPlaying(voiceId);
|
||||
final isComplete = voiceProvider.isComplete(voiceId);
|
||||
|
||||
if (_isRequesting && isComplete) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_errorText = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (_autoPlayWhenReady && isComplete && !isPlaying) {
|
||||
_autoPlayWhenReady = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!mounted) return;
|
||||
await voiceProvider.play(voiceId);
|
||||
});
|
||||
}
|
||||
|
||||
final received = session?.receivedCount ?? 0;
|
||||
final total = session?.total ?? envelope?.total ?? 0;
|
||||
final playbackProgress = voiceProvider.playbackProgress(voiceId);
|
||||
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);
|
||||
final durationLabel = _formatDuration(durationSec);
|
||||
final modeLabel = session?.mode.label ?? envelope?.mode.label ?? '?';
|
||||
final waveformBars = _resolveWaveformBars(
|
||||
session: session,
|
||||
messageText: widget.message.text,
|
||||
);
|
||||
final txEstimate = _resolveVoiceTransmitEstimate(
|
||||
session: session,
|
||||
envelope: envelope,
|
||||
messageText: widget.message.text,
|
||||
pathLen: effectivePathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
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: handlePrimaryTap,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isSentByMe
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.secondaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
isPlaying
|
||||
? Icons.stop
|
||||
: (_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
|
||||
.withValues(alpha: isReceivingData ? 0.6 : 1.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isPlaying)
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: LinearProgressIndicator(
|
||||
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),
|
||||
Text(
|
||||
_buildStatusText(
|
||||
durationLabel: durationLabel,
|
||||
modeLabel: modeLabel,
|
||||
txEstimateLabel: txEstimateLabel,
|
||||
received: received,
|
||||
total: total,
|
||||
isComplete: isComplete,
|
||||
isRequesting: _isRequesting,
|
||||
isReceivingData: isReceivingData,
|
||||
isPartialRequest: _isPartialRequest,
|
||||
errorText: _errorText,
|
||||
requestingLabel: AppLocalizations.of(
|
||||
context,
|
||||
)!.requestingVoice,
|
||||
fetchingMissingLabel: AppLocalizations.of(
|
||||
context,
|
||||
)!.fetchingMissingVoiceFragments,
|
||||
receivingVoiceLabel: AppLocalizations.of(
|
||||
context,
|
||||
)!.receivingVoice,
|
||||
eta: eta,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
transferCount: transferCount,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _requestAndPlayVoice(
|
||||
String sessionId, {
|
||||
VoiceEnvelope? envelope,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
int? radioCr,
|
||||
int pathLen = 0,
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
setState(() {
|
||||
_isRequesting = true;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = true;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final voiceProvider = context.read<VoiceProvider>();
|
||||
voiceProvider.resumeIncomingSession(sessionId);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final appProvider = context.read<AppProvider>();
|
||||
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: connectionProvider.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchVoice,
|
||||
AppLocalizations.of(context)!.senderContactUnknown,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchVoice,
|
||||
AppLocalizations.of(context)!.senderRouteUnknown,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchVoice,
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unreachable) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchVoice,
|
||||
AppLocalizations.of(context)!.senderRouteNoPathResponse,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = resolution.target!;
|
||||
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
await connectionProvider.getContacts();
|
||||
if (!mounted) return;
|
||||
resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
refreshContacts: connectionProvider.getContacts,
|
||||
isSentByMe: widget.isSentByMe,
|
||||
recipientPublicKey: widget.message.recipientPublicKey,
|
||||
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
|
||||
senderName: widget.message.senderName,
|
||||
maxFetchHops: _maxFetchHops,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchVoice,
|
||||
AppLocalizations.of(context)!.senderContactUnknown,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchVoice,
|
||||
AppLocalizations.of(context)!.senderRouteUnknown,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolution.failure == TransmissionTargetFailure.tooFar) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchVoice,
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
sender = resolution.target!;
|
||||
routeVerified = await appProvider.verifyRawTransportRoute(sender);
|
||||
if (!mounted) return;
|
||||
if (!routeVerified) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchVoice,
|
||||
AppLocalizations.of(context)!.senderRouteNoRawResponse,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sender.routeHopCount >= 2) {
|
||||
_showToast(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.voiceFetchOverHops('${sender.routeHopCount}'),
|
||||
);
|
||||
}
|
||||
|
||||
final deviceKey = connectionProvider.deviceInfo.publicKey;
|
||||
if (deviceKey == null || deviceKey.length < 6) {
|
||||
_clearRequestState();
|
||||
await _showBlockingAlert(
|
||||
AppLocalizations.of(context)!.cannotFetchVoice,
|
||||
AppLocalizations.of(context)!.deviceKeyUnavailable,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final requesterKey6 = deviceKey
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
final missing = voiceProvider.missingPacketIndices(sessionId);
|
||||
final totalPackets = sessionPacketCount(
|
||||
voiceProvider: voiceProvider,
|
||||
sessionId: sessionId,
|
||||
envelope: envelope,
|
||||
);
|
||||
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,
|
||||
)
|
||||
: VoiceFetchRequest(sessionId: sessionId, requesterKey6: requesterKey6);
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
'🎙️ [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.routeEncodedPathLen,
|
||||
payload: request.encodeBinary(),
|
||||
);
|
||||
} catch (_) {
|
||||
_setUnavailable();
|
||||
return;
|
||||
}
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final effectivePathLen = sender.routeHasPath
|
||||
? sender.routeHopCount
|
||||
: pathLen;
|
||||
final estimatedDurationMs =
|
||||
envelope != null &&
|
||||
totalPackets > 0 &&
|
||||
missing.isNotEmpty &&
|
||||
missing.length < totalPackets
|
||||
? ((envelope.durationMs * missing.length) / totalPackets).round()
|
||||
: envelope?.durationMs;
|
||||
final txEstimate = envelope != null
|
||||
? estimateVoiceTransmitDuration(
|
||||
packetCount: isPartialResume ? missing.length : envelope.total,
|
||||
mode: envelope.mode,
|
||||
durationMs: estimatedDurationMs ?? envelope.durationMs,
|
||||
pathLen: effectivePathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
)
|
||||
: const Duration(seconds: 15);
|
||||
_requestTimeoutTimer?.cancel();
|
||||
_requestTimeoutTimer = TransferTimeout.start(
|
||||
txEstimate: txEstimate,
|
||||
onTimeout: () {
|
||||
if (mounted && _isRequesting) {
|
||||
_setUnavailable();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int sessionPacketCount({
|
||||
required VoiceProvider voiceProvider,
|
||||
required String sessionId,
|
||||
required VoiceEnvelope? envelope,
|
||||
}) {
|
||||
return voiceProvider.session(sessionId)?.total ?? envelope?.total ?? 0;
|
||||
}
|
||||
|
||||
void _setUnavailable() {
|
||||
if (!mounted) return;
|
||||
_showToast(AppLocalizations.of(context)!.voiceUnavailable);
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
_errorText = AppLocalizations.of(context)!.voiceUnavailable;
|
||||
});
|
||||
}
|
||||
|
||||
void _clearRequestState() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelReceive(String sessionId) {
|
||||
if (!mounted) return;
|
||||
_requestTimeoutTimer?.cancel();
|
||||
context.read<VoiceProvider>().cancelIncomingSession(sessionId);
|
||||
_showToast(AppLocalizations.of(context)!.voiceReceiveCanceled);
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_isPartialRequest = false;
|
||||
_autoPlayWhenReady = false;
|
||||
_errorText = AppLocalizations.of(context)!.voiceReceiveCanceled;
|
||||
});
|
||||
}
|
||||
|
||||
void _showToast(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showBlockingAlert(String title, String message) async {
|
||||
if (!mounted) return;
|
||||
_showToast('$title: $message');
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatDuration(double seconds) {
|
||||
final s = seconds.round();
|
||||
if (s < 60) return '${s}s';
|
||||
return '${s ~/ 60}m ${s % 60}s';
|
||||
}
|
||||
|
||||
static String _buildStatusText({
|
||||
required String durationLabel,
|
||||
required String modeLabel,
|
||||
required String txEstimateLabel,
|
||||
required int received,
|
||||
required int total,
|
||||
required bool isComplete,
|
||||
required bool isRequesting,
|
||||
required bool isReceivingData,
|
||||
required bool isPartialRequest,
|
||||
required String? errorText,
|
||||
required String requestingLabel,
|
||||
required String fetchingMissingLabel,
|
||||
required String receivingVoiceLabel,
|
||||
required Duration? eta,
|
||||
required bool isSentByMe,
|
||||
required int transferCount,
|
||||
}) {
|
||||
if (errorText != null) return errorText;
|
||||
final progress = total > 0 ? ' ($received/$total)' : '';
|
||||
if (isRequesting) {
|
||||
final actionLabel = isPartialRequest
|
||||
? fetchingMissingLabel
|
||||
: requestingLabel;
|
||||
return '$actionLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||
}
|
||||
if (isReceivingData) {
|
||||
return '$receivingVoiceLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
|
||||
}
|
||||
if (!isComplete && total > 0) {
|
||||
return isSentByMe
|
||||
? '🎙️ $durationLabel · $modeLabel$progress · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
|
||||
}
|
||||
return isSentByMe
|
||||
? '🎙️ $durationLabel · $modeLabel · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
|
||||
: '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
|
||||
}
|
||||
|
||||
List<double> _resolveWaveformBars({
|
||||
required VoiceSession? session,
|
||||
required String messageText,
|
||||
}) {
|
||||
if (session != null) {
|
||||
final fromSession = VoiceWaveform.buildBarsFromPackets(session.packets);
|
||||
if (fromSession.any((v) => v > 0.0)) return fromSession;
|
||||
}
|
||||
|
||||
final legacyPacket = VoicePacket.tryParseText(messageText);
|
||||
if (legacyPacket != null) {
|
||||
final fromLegacy = VoiceWaveform.buildBarsFromPackets([legacyPacket]);
|
||||
if (fromLegacy.any((v) => v > 0.0)) return fromLegacy;
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
Duration _resolveVoiceTransmitEstimate({
|
||||
required VoiceSession? session,
|
||||
required VoiceEnvelope? envelope,
|
||||
required String messageText,
|
||||
required int pathLen,
|
||||
required int? radioBw,
|
||||
required int? radioSf,
|
||||
required int? radioCr,
|
||||
}) {
|
||||
if (session != null) {
|
||||
final fromSession = estimateVoiceTransmitDurationFromPackets(
|
||||
packets: session.packets,
|
||||
pathLen: pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
if (fromSession > Duration.zero) return fromSession;
|
||||
}
|
||||
|
||||
if (envelope != null) {
|
||||
return estimateVoiceTransmitDuration(
|
||||
mode: envelope.mode,
|
||||
packetCount: envelope.total,
|
||||
durationMs: envelope.durationMs,
|
||||
pathLen: pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
|
||||
final legacyPacket = VoicePacket.tryParseText(messageText);
|
||||
if (legacyPacket != null) {
|
||||
return estimateVoiceTransmitDuration(
|
||||
mode: legacyPacket.mode,
|
||||
packetCount: legacyPacket.total,
|
||||
durationMs: legacyPacket.durationMs * legacyPacket.total,
|
||||
pathLen: pathLen,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
);
|
||||
}
|
||||
return Duration.zero;
|
||||
}
|
||||
|
||||
static String _formatTransmitEstimate(Duration value) {
|
||||
if (value <= Duration.zero) return '~0s tx';
|
||||
if (value.inSeconds < 60) return '~${value.inSeconds}s tx';
|
||||
final minutes = value.inMinutes;
|
||||
final seconds = value.inSeconds % 60;
|
||||
return '~${minutes}m ${seconds}s tx';
|
||||
}
|
||||
|
||||
static String _formatEta(Duration? eta) {
|
||||
if (eta == null || eta <= Duration.zero) return 'ETA --';
|
||||
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
|
||||
final minutes = eta.inMinutes;
|
||||
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.
|
||||
class _WaveformBar extends StatelessWidget {
|
||||
final bool isComplete;
|
||||
final List<double> bars;
|
||||
const _WaveformBar({required this.isComplete, required this.bars});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final heights = bars.isEmpty
|
||||
? const [8.0, 12.0, 10.0, 14.0, 9.0, 12.0, 8.0, 11.0, 10.0, 13.0]
|
||||
: bars.map((v) => 6.0 + (v.clamp(0.0, 1.0) * 14.0)).toList();
|
||||
final color = isComplete
|
||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)
|
||||
: Colors.grey.withValues(alpha: 0.5);
|
||||
return Row(
|
||||
children: heights
|
||||
.map(
|
||||
(h) => Container(
|
||||
width: 3,
|
||||
height: h,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user