Files
pyramid/lib/features/chat/chat_composer.dart
T
Bernd SteckmeisterandClaude Opus 5.5 382747cdb1 feat: Handy - groessere Symbole, Suche/Mitglieder im Menue, Kontakt-Banner repariert, Schrift 110 %, federnde Knoepfe
Bernds Feedback 2026-10-07:
- Schrift-Standard Handy 110 % (statt 115 %).
- PyrIconBtn: im Handy-Layout Symbol 22 px (vorher fest 16), Flaeche 40,
  Tippflaeche 44; Farben aus dem Farbmodus statt fest verdrahtet.
- Eingabeleiste: Anhaengen/GIF/Emoji/Senden auf dem Handy 22-23 px statt
  12-13 px.
- Chat-Kopf (Handy): Anrufen + Videoanruf sichtbar, Suchen + Mitglieder im
  Drei-Punkte-Menue.
- Aufklappbarer DM-Kopf als Ebenen: Zurueck + Symbole bleiben oben,
  Profilbereich gleitet darunter und nutzt die volle Breite (vorher Name zu
  "p..." gekuerzt, Symbole rutschten in die Mitte); Kuerzel im Avatar
  mittig, skaliert mit.
- SpringPress (widgets/spring_press.dart): federnde Druck-Rueckmeldung,
  aus bei "Animationen 0 %".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 18:13:32 +02:00

646 lines
21 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 '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),
decoration: BoxDecoration(
color: pt.bg2,
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: 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;
}
}