Files
meshcore-sar_android/lib/widgets/messages/messages_composer.dart
2026-03-22 19:04:43 +01:00

598 lines
20 KiB
Dart

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 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() onSendMessage;
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.mentionSuggestions,
required this.mentionQuery,
required this.onMentionSelected,
required this.onShowComposerActions,
required this.onShowRecipientSelector,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
required this.onSendMessage,
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 (regionScopeName != null &&
onRegionScopeTap != null) ...[
const SizedBox(width: 6),
_RegionScopeChip(
name: regionScopeName!,
onTap: onRegionScopeTap!,
),
],
const SizedBox(width: 8),
Expanded(
child: _DestinationSelector(
destinationLabel: destinationLabel,
destinationAvatar: destinationAvatar,
onTap: onShowRecipientSelector,
),
),
],
),
const SizedBox(height: 6),
ListenableBuilder(
listenable: Listenable.merge([
textController,
focusNode,
]),
builder: (context, _) {
final canSendText =
!isRecording &&
!isSendingVoice &&
textController.text.trim().isNotEmpty;
final semanticsLabel = isRecording
? 'Recording... release to send voice'
: (isSendingVoice
? 'Sending voice...'
: voiceSupported
? 'Send (long press to record voice)'
: '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,
onStartVoiceRecording: onStartVoiceRecording,
onStopAndSendVoice: onStopAndSendVoice,
),
],
);
},
),
],
),
),
),
),
),
],
),
);
}
}
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 ? 'Stop recording' : 'More actions',
onPressed: onPressed,
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
),
);
}
}
class _DestinationSelector extends StatelessWidget {
final String destinationLabel;
final Widget destinationAvatar;
final VoidCallback onTap;
const _DestinationSelector({
required this.destinationLabel,
required this.destinationAvatar,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Ink(
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 8),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more_rounded,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
),
);
}
}
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 Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
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,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
enabled: canSendText || (voiceSupported && !isSendingVoice),
label: semanticsLabel,
onTap: canSendText ? onSendMessage : null,
onLongPress: (voiceSupported && !isSendingVoice)
? () {
if (isRecording) {
onStopAndSendVoice();
return;
}
onStartVoiceRecording();
}
: null,
child: Tooltip(
message: semanticsLabel,
excludeFromSemantics: true,
child: GestureDetector(
excludeFromSemantics: true,
onTap: canSendText ? onSendMessage : null,
onLongPressStart: (voiceSupported && !isSendingVoice)
? (_) => onStartVoiceRecording()
: null,
onLongPressEnd: (voiceSupported && isRecording)
? (_) => onStopAndSendVoice()
: null,
onLongPressCancel: (voiceSupported && isRecording)
? onStopAndSendVoice
: 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,
),
),
],
),
),
);
}
}