Files
meshcore-sar_android/lib/widgets/messages/messages_composer.dart
Janez T ba27843166 feat: UX polish, localization, advert relocation, and map filter fixes
- i18n: translate connection screen + chat tab keys into 13 locales;
  add "Send my contact" (advert) strings in all locales
- feat: move self-advert from home header into composer "+" action menu
  (new lib/utils/advert_helper.dart, always shows Flood/Direct sheet)
- fix: hide-repeaters map toggle was bypassed in simple mode
- fix: simple mode map now shows only favourite chat contacts
- fix: wrap ListTiles in Material (contact_tile secondary actions,
  inferred contact group card) to satisfy Flutter ink assertions
- device-authoritative contact/channel sync and UX review fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:01:10 +02:00

669 lines
23 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 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,
),
),
],
),
),
);
}
}