Files
pyramid/lib/features/chat/chat_composer.dart
T
Bernd SteckmeisterandClaude Opus 5.5 223c9448f6 feat: schwebende Eingabeleiste - Chat laeuft darunter weiter (Milchglas), Flaeche faengt Beruehrungen ab
Bernds Wunsch 2026-10-07: Die Nachrichtenliste reicht jetzt bis ganz unten;
Tipp-Anzeige und Eingabefeld schweben darueber. Um das Feld herum ist die
Flaeche klar (Chat sichtbar), aber beruehrungsdicht - dort reagiert nur die
Eingabe. Hinter dem Feld selbst Milchglas (Unschaerfe + 78 % Flaeche), Rahmen
und Fokus-Ring aussen. Die Liste bekommt unten genau die gemessene Hoehe der
Leiste als Abstand (MeasureSize; waechst mit Antwort-Leiste/mehrzeiligem
Text), damit die neueste Nachricht ganz ueber die Leiste scrollt; der
Nach-unten-Knopf sitzt darueber.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 21:26:33 +02:00

658 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/widgets/spring_press.dart';
import 'package:pyramid/features/chat/attachment_dialog.dart';
import 'package:pyramid/features/chat/emoji_picker.dart';
import 'package:pyramid/features/chat/gif_sticker_picker.dart';
import 'package:pyramid/utils/clipboard_image.dart';
class ChatComposer extends ConsumerStatefulWidget {
final Room room;
final Event? replyTo;
final VoidCallback? onClearReply;
final VoidCallback? onOpenEmoji;
const ChatComposer({
super.key,
required this.room,
this.replyTo,
this.onClearReply,
this.onOpenEmoji,
});
@override
ConsumerState<ChatComposer> createState() => _ChatComposerState();
}
class _ChatComposerState extends ConsumerState<ChatComposer> {
final _ctrl = TextEditingController();
late final FocusNode _focus;
bool _canSend = false;
OverlayEntry? _gifOverlay;
OverlayEntry? _emojiOverlay;
Timer? _typingTimer;
bool _isTyping = false;
@override
void initState() {
super.initState();
_focus = FocusNode(onKeyEvent: _handleKeyEvent);
_ctrl.addListener(_onTextChanged);
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.keyV &&
HardwareKeyboard.instance.isControlPressed) {
final bytes = getClipboardImageBytes();
if (bytes != null) {
_pasteClipboardImage(bytes);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
Future<void> _pasteClipboardImage(Uint8List bytes) async {
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/paste_${DateTime.now().millisecondsSinceEpoch}.png');
await file.writeAsBytes(bytes);
if (!mounted) return;
await AttachmentDialog.show(context, [file], widget.room, replyTo: widget.replyTo);
widget.onClearReply?.call();
file.delete().catchError((_) => file);
}
/// Bilder, die die Android-Tastatur einfügt (Gboard-Clipboard-Chip,
/// GIF-Auswahl der Tastatur etc.) — landen im Anhang-Dialog mit Vorschau.
Future<void> _handleInsertedContent(KeyboardInsertedContent content) async {
final bytes = content.data;
if (bytes == null || bytes.isEmpty) return;
final ext = content.mimeType.contains('/')
? content.mimeType.split('/').last
: 'png';
final dir = await getTemporaryDirectory();
final file = File(
'${dir.path}/keyboard_${DateTime.now().millisecondsSinceEpoch}.$ext');
await file.writeAsBytes(bytes);
if (!mounted) return;
await AttachmentDialog.show(context, [file], widget.room, replyTo: widget.replyTo);
widget.onClearReply?.call();
file.delete().catchError((_) => file);
}
void _onTextChanged() {
final has = _ctrl.text.trim().isNotEmpty;
if (has != _canSend) setState(() => _canSend = has);
_updateTyping(has);
}
void _updateTyping(bool typing) {
final sendTyping = ref.read(privacyTypingProvider);
if (!sendTyping) return;
if (typing) {
// Reset the auto-stop timer on every keystroke.
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 7), () => _sendTyping(false));
if (!_isTyping) _sendTyping(true);
} else {
_typingTimer?.cancel();
if (_isTyping) _sendTyping(false);
}
}
void _sendTyping(bool typing) {
_isTyping = typing;
widget.room
.setTyping(typing, timeout: typing ? 10000 : null)
.catchError((_) {});
}
@override
void dispose() {
_typingTimer?.cancel();
if (_isTyping) widget.room.setTyping(false).catchError((_) {});
_gifOverlay?.remove();
_emojiOverlay?.remove();
_ctrl.dispose();
_focus.dispose();
super.dispose();
}
void _toggleGifPicker() {
if (_gifOverlay != null) {
_gifOverlay!.remove();
_gifOverlay = null;
return;
}
final platform = Theme.of(context).platform;
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS;
if (isMobile) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
top: 40,
),
child: GifStickerPicker(
room: widget.room,
onClose: () => Navigator.of(ctx).pop(),
),
),
);
return;
}
final box = context.findRenderObject() as RenderBox?;
final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
final size = box?.size ?? Size.zero;
_gifOverlay = OverlayEntry(builder: (ctx) {
final screenSize = MediaQuery.sizeOf(ctx);
const pickerW = 380.0;
const pickerH = 460.0;
final left = (offset.dx + size.width - pickerW).clamp(8.0, screenSize.width - pickerW - 8);
final top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8);
return Material(
type: MaterialType.transparency,
child: Stack(children: [
GestureDetector(
onTap: () { _gifOverlay?.remove(); _gifOverlay = null; },
behavior: HitTestBehavior.translucent,
child: const SizedBox.expand(),
),
Positioned(
left: left,
top: top,
child: GifStickerPicker(
room: widget.room,
onClose: () { _gifOverlay?.remove(); _gifOverlay = null; },
),
),
]),
);
});
Overlay.of(context).insert(_gifOverlay!);
}
void _toggleEmojiPicker() {
if (_emojiOverlay != null) {
_emojiOverlay!.remove();
_emojiOverlay = null;
return;
}
final platform = Theme.of(context).platform;
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS;
void onEmojiSelected(String emoji, BuildContext? ctx) {
final pos = _ctrl.selection.baseOffset;
final text = _ctrl.text;
final before = pos < 0 ? text : text.substring(0, pos);
final after = pos < 0 ? '' : text.substring(pos);
_ctrl.value = TextEditingValue(
text: '$before$emoji$after',
selection: TextSelection.collapsed(
offset: before.length + emoji.length,
),
);
if (isMobile && ctx != null) {
Navigator.of(ctx).pop();
} else {
_emojiOverlay?.remove();
_emojiOverlay = null;
}
_focus.requestFocus();
}
if (isMobile) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
top: 40,
),
child: EmojiPicker(
onEmojiSelected: (emoji) => onEmojiSelected(emoji, ctx),
onClose: () => Navigator.of(ctx).pop(),
),
),
);
return;
}
final box = context.findRenderObject() as RenderBox?;
final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
final size = box?.size ?? Size.zero;
_emojiOverlay = OverlayEntry(builder: (ctx) {
final screenSize = MediaQuery.sizeOf(ctx);
const pickerW = 340.0;
const pickerH = 380.0;
final left = (offset.dx + size.width - pickerW).clamp(8.0, screenSize.width - pickerW - 8);
final top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8);
return Material(
type: MaterialType.transparency,
child: Stack(children: [
GestureDetector(
onTap: () { _emojiOverlay?.remove(); _emojiOverlay = null; },
behavior: HitTestBehavior.translucent,
child: const SizedBox.expand(),
),
Positioned(
left: left,
top: top,
child: EmojiPicker(
onEmojiSelected: (emoji) => onEmojiSelected(emoji, null),
onClose: () { _emojiOverlay?.remove(); _emojiOverlay = null; },
),
),
]),
);
});
Overlay.of(context).insert(_emojiOverlay!);
}
Future<void> _pickAttachment() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null || result.files.isEmpty) return;
if (!mounted) return;
final files = result.files
.where((f) => f.path != null)
.map((f) => File(f.path!))
.toList();
if (files.isEmpty) return;
await AttachmentDialog.show(context, files, widget.room, replyTo: widget.replyTo);
widget.onClearReply?.call();
}
Future<void> _send() async {
final text = _ctrl.text.trim();
if (text.isEmpty) return;
_ctrl.clear();
setState(() => _canSend = false);
// Stop typing indicator immediately before sending.
_typingTimer?.cancel();
if (_isTyping) _sendTyping(false);
await widget.room.sendTextEvent(text, inReplyTo: widget.replyTo);
widget.onClearReply?.call();
_focus.requestFocus();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final size = MediaQuery.sizeOf(context);
final isSmall = size.width < 400;
// Handy: größere Symbole (vorher 12–13 px, Bernds Feedback 2026-10-07).
final mobile = isMobileLayout(size);
final roomName = widget.room.getLocalizedDisplayname();
final placeholder = widget.room.isDirectChat
? 'Nachricht…'
: 'Nachricht an #$roomName';
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.replyTo != null)
_ReplyBar(
event: widget.replyTo!,
onClear: widget.onClearReply ?? () {},
pt: pt,
),
Padding(
// Bottom inset matches the profile block in the rooms list (8px) so
// the composer sits flush with it.
padding: EdgeInsets.fromLTRB(isSmall ? 8 : 16, 0, isSmall ? 8 : 16, 8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
// Tuned so the single-line composer is flush with the profile
// block in the rooms list (~89px on the user's display). Grows for
// multiline.
constraints: const BoxConstraints(minHeight: 53),
// Rahmen + Fokus-Ring außen (würden vom Zuschnitt sonst
// abgeschnitten), innen Milchglas: der Chat läuft unter dem
// schwebenden Feld durch und scheint unscharf durch.
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(pt.rBase + 4),
border: Border.all(
color: _focus.hasFocus ? pt.accent : pt.border,
),
boxShadow: _focus.hasFocus
? [BoxShadow(color: pt.accentSoft, blurRadius: 0, spreadRadius: 3)]
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rBase + 3),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: ColoredBox(
color: pt.bg2.withValues(alpha: 0.78),
child: Focus(
onFocusChange: (_) => setState(() {}),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Attach button
_ComposerIconBtn(
icon: Icons.add_rounded,
tooltip: 'Datei anhängen',
pt: pt,
size: mobile ? 42 : 30,
onTap: _pickAttachment,
),
// Text input
Expanded(
child: TextField(
controller: _ctrl,
focusNode: _focus,
minLines: 1,
maxLines: 8,
style: TextStyle(color: pt.fg, fontSize: 14),
cursorColor: pt.accent,
decoration: InputDecoration(
hintText: placeholder,
hintStyle: TextStyle(color: pt.fgDim, fontSize: 14),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: size.width < 600 ? 4 : 8,
vertical: 10,
),
isDense: true,
),
inputFormatters: [
if (!(Platform.isAndroid || Platform.isIOS))
_SendOnEnterFormatter(onSend: _send),
],
// Meldet der Android-Tastatur, dass dieses Feld Bilder
// annimmt — dadurch bietet z.B. Gboard frisch kopierte
// Bilder direkt in der Vorschlagsleiste an.
contentInsertionConfiguration: Platform.isAndroid
? ContentInsertionConfiguration(
allowedMimeTypes: const [
'image/gif',
'image/png',
'image/jpeg',
'image/webp',
],
onContentInserted: _handleInsertedContent,
)
: null,
),
),
// Inline tools
Row(
mainAxisSize: MainAxisSize.min,
children: [
_ComposerIconBtn(
icon: Icons.gif_box_outlined,
tooltip: 'GIF',
pt: pt,
size: mobile ? 40 : 26,
onTap: _toggleGifPicker,
),
_ComposerIconBtn(
icon: Icons.tag_faces_rounded,
tooltip: 'Emoji',
pt: pt,
size: mobile ? 40 : 26,
onTap: _toggleEmojiPicker,
),
if (size.width > 600)
_ComposerIconBtn(
icon: Icons.alternate_email_rounded,
tooltip: 'Mention',
pt: pt,
size: 26,
),
],
),
// Send button — only on mobile
if (Platform.isAndroid || Platform.isIOS)
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 8, 4),
child: _SendButton(
canSend: _canSend,
pt: pt,
onTap: _send,
size: mobile ? 42 : 36,
),
),
],
),
),
),
),
),
),
),
],
),
);
}
}
class _ComposerIconBtn extends StatefulWidget {
final IconData icon;
final String tooltip;
final PyramidTheme pt;
final double size;
final VoidCallback? onTap;
const _ComposerIconBtn({
required this.icon,
required this.tooltip,
required this.pt,
required this.size,
this.onTap,
});
@override
State<_ComposerIconBtn> createState() => _ComposerIconBtnState();
}
class _ComposerIconBtnState extends State<_ComposerIconBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final isMobile = isMobileLayout(MediaQuery.sizeOf(context));
final touchSize = isMobile ? 44.0 : widget.size;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Tooltip(
message: widget.tooltip,
waitDuration: const Duration(milliseconds: 500),
child: SpringPress(
enabled: widget.onTap != null,
child: SizedBox(
width: touchSize,
height: touchSize,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: _hovered ? widget.pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Icon(
widget.icon,
size: widget.size * 0.55,
color: _hovered ? widget.pt.fg : widget.pt.fgMuted,
),
),
),
),
),
),
),
),
);
}
}
class _SendButton extends StatefulWidget {
final bool canSend;
final PyramidTheme pt;
final VoidCallback onTap;
final double size;
const _SendButton({
required this.canSend,
required this.pt,
required this.onTap,
this.size = 36,
});
@override
State<_SendButton> createState() => _SendButtonState();
}
class _SendButtonState extends State<_SendButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final active = widget.canSend;
return MouseRegion(
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: active ? widget.onTap : null,
child: SpringPress(
enabled: active,
pressedScale: 0.82,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack,
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: active ? pt.accent : pt.bg3,
borderRadius: BorderRadius.circular(pt.rSm),
boxShadow: active && _hovered
? [BoxShadow(color: pt.accentGlow, blurRadius: 12, offset: const Offset(0, 4))]
: null,
),
transform: active
? (_hovered
? (Matrix4.diagonal3Values(1.1, 1.1, 1.0)..rotateZ(-0.26))
: Matrix4.diagonal3Values(1.05, 1.05, 1.0))
: Matrix4.identity(),
transformAlignment: Alignment.center,
child: Center(
child: Icon(
Icons.send_rounded,
size: widget.size * 0.45,
color: active ? pt.accentFg : pt.fgDim,
),
),
),
),
),
);
}
}
class _ReplyBar extends StatelessWidget {
final Event event;
final VoidCallback onClear;
final PyramidTheme pt;
const _ReplyBar({required this.event, required this.onClear, required this.pt});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 4),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: pt.border),
left: BorderSide(color: pt.accent, width: 3),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.senderFromMemoryOrFallback.calcDisplayname(),
style: TextStyle(
color: pt.accent,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
Text(
event.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: pt.fgMuted, fontSize: 12),
),
],
),
),
GestureDetector(
onTap: onClear,
child: Icon(Icons.close_rounded, size: 16, color: pt.fgDim),
),
],
),
);
}
}
class _SendOnEnterFormatter extends TextInputFormatter {
final VoidCallback onSend;
_SendOnEnterFormatter({required this.onSend});
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (newValue.text.endsWith('\n') && !oldValue.text.endsWith('\n')) {
// Check if shift is pressed (can't detect here, just send on enter)
// For multiline we'd need keyboard shortcuts
WidgetsBinding.instance.addPostFrameCallback((_) => onSend());
return oldValue;
}
return newValue;
}
}