wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)

6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.)
als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen
Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
This commit is contained in:
Bernd Steckmeister
2026-07-03 05:47:18 +02:00
parent d706ace35f
commit 25ed765a03
195 changed files with 47784 additions and 1236 deletions
+328
View File
@@ -0,0 +1,328 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:mime/mime.dart';
import 'package:pyramid/core/theme.dart';
class AttachmentDialog extends StatefulWidget {
final List<File> files;
final Room room;
final Event? replyTo;
const AttachmentDialog({
super.key,
required this.files,
required this.room,
this.replyTo,
});
static Future<bool> show(
BuildContext context,
List<File> files,
Room room, {
Event? replyTo,
}) async {
// Snappy Scale+Fade-Eingangsanimation statt Standard-Einblendung.
return await showGeneralDialog<bool>(
context: context,
barrierDismissible: true,
barrierLabel: 'Anhang',
barrierColor: Colors.black54,
transitionDuration: const Duration(milliseconds: 180),
pageBuilder: (_, __, ___) =>
AttachmentDialog(files: files, room: room, replyTo: replyTo),
transitionBuilder: (_, anim, __, child) {
final curved =
CurvedAnimation(parent: anim, curve: Curves.easeOutBack);
return FadeTransition(
opacity: anim,
child: ScaleTransition(scale: Tween(begin: 0.92, end: 1.0).animate(curved), child: child),
);
},
) ??
false;
}
@override
State<AttachmentDialog> createState() => _AttachmentDialogState();
}
class _AttachmentDialogState extends State<AttachmentDialog> {
final _msgCtrl = TextEditingController();
bool _sending = false;
String? _error;
bool _compress = false;
bool get _hasImages => widget.files.any((f) {
final mime = lookupMimeType(f.path.split(Platform.pathSeparator).last) ?? '';
return mime.startsWith('image/');
});
@override
void dispose() {
_msgCtrl.dispose();
super.dispose();
}
Future<void> _send() async {
setState(() { _sending = true; _error = null; });
try {
for (final file in widget.files) {
final bytes = await file.readAsBytes();
final filename = file.path.split(Platform.pathSeparator).last;
final mimeType = lookupMimeType(filename) ?? 'application/octet-stream';
final matrixFile = MatrixFile(bytes: bytes, name: filename, mimeType: mimeType);
final isImage = mimeType.startsWith('image/');
await widget.room.sendFileEvent(
matrixFile,
inReplyTo: widget.replyTo,
shrinkImageMaxDimension: (_compress && isImage) ? 1600 : null,
);
}
if (_msgCtrl.text.trim().isNotEmpty) {
await widget.room.sendTextEvent(
_msgCtrl.text.trim(),
inReplyTo: widget.replyTo,
);
}
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
setState(() { _sending = false; _error = e.toString().split('\n').first; });
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return Dialog(
backgroundColor: pt.bg1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rLg),
side: BorderSide(color: pt.border),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480, minWidth: 320),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Row(
children: [
Icon(Icons.attach_file_rounded, size: 18, color: pt.accent),
const SizedBox(width: 8),
Text(
'${widget.files.length} Datei${widget.files.length == 1 ? '' : 'en'} senden',
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600),
),
const Spacer(),
GestureDetector(
onTap: () => Navigator.of(context).pop(false),
child: Icon(Icons.close_rounded, size: 18, color: pt.fgDim),
),
],
),
const SizedBox(height: 16),
// File list
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 160),
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.files.length,
itemBuilder: (ctx, i) => _FilePreview(file: widget.files[i], pt: pt),
),
),
const SizedBox(height: 16),
// Caption field
TextField(
controller: _msgCtrl,
style: TextStyle(color: pt.fg, fontSize: 14),
cursorColor: pt.accent,
maxLines: 3,
minLines: 1,
decoration: InputDecoration(
hintText: 'Nachricht hinzufügen (optional)',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 14),
filled: true,
fillColor: pt.bg2,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rBase),
borderSide: BorderSide(color: pt.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rBase),
borderSide: BorderSide(color: pt.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(pt.rBase),
borderSide: BorderSide(color: pt.accent),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
isDense: true,
),
),
// Image quality toggle
if (_hasImages) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: Row(
children: [
Icon(Icons.high_quality_outlined, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Text('Hohe Qualität', style: TextStyle(color: pt.fg, fontSize: 13)),
const Spacer(),
Switch.adaptive(
value: !_compress,
onChanged: (v) => setState(() => _compress = !v),
activeColor: pt.accent,
),
],
),
),
],
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: pt.danger, fontSize: 12)),
],
const SizedBox(height: 16),
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _sending ? null : () => Navigator.of(context).pop(false),
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _sending ? null : _send,
style: FilledButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rBase),
),
),
icon: _sending
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: pt.accentFg,
),
)
: const Icon(Icons.send_rounded, size: 14),
label: const Text('Senden'),
),
],
),
],
),
),
),
);
}
}
class _FilePreview extends StatefulWidget {
final File file;
final PyramidTheme pt;
const _FilePreview({required this.file, required this.pt});
@override
State<_FilePreview> createState() => _FilePreviewState();
}
class _FilePreviewState extends State<_FilePreview> {
int? _bytes;
@override
void initState() {
super.initState();
widget.file.length().then((b) { if (mounted) setState(() => _bytes = b); });
}
@override
Widget build(BuildContext context) {
final filename = widget.file.path.split(Platform.pathSeparator).last;
final mime = lookupMimeType(filename) ?? 'application/octet-stream';
final isImage = mime.startsWith('image/');
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: widget.pt.bg2,
borderRadius: BorderRadius.circular(widget.pt.rBase),
border: Border.all(color: widget.pt.border),
),
child: Row(
children: [
if (isImage)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(widget.file, width: 40, height: 40, fit: BoxFit.cover),
)
else
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: widget.pt.bg3,
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Icon(_mimeIcon(mime), size: 20, color: widget.pt.fgDim),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
filename,
style: TextStyle(color: widget.pt.fg, fontSize: 13),
overflow: TextOverflow.ellipsis,
),
if (_bytes != null)
Text(
_formatSize(_bytes!),
style: TextStyle(color: widget.pt.fgDim, fontSize: 11),
),
],
),
),
],
),
);
}
IconData _mimeIcon(String mime) {
if (mime.startsWith('video/')) return Icons.videocam_outlined;
if (mime.startsWith('audio/')) return Icons.audio_file_outlined;
if (mime.contains('pdf')) return Icons.picture_as_pdf_outlined;
if (mime.contains('zip') || mime.contains('tar') || mime.contains('gz')) {
return Icons.folder_zip_outlined;
}
return Icons.insert_drive_file_outlined;
}
String _formatSize(int bytes) {
if (bytes < 1024) return '${bytes} B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
+630
View File
@@ -0,0 +1,630 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
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/theme.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;
final roomName = widget.room.getLocalizedDisplayname();
final placeholder = widget.room.isDirectChat
? 'Message…'
: 'Message #$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: size.width < 600 ? 24 : 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: size.width < 600 ? 22 : 26,
onTap: _toggleGifPicker,
),
_ComposerIconBtn(
icon: Icons.tag_faces_rounded,
tooltip: 'Emoji',
pt: pt,
size: size.width < 600 ? 22 : 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,
),
),
],
),
),
),
),
],
),
);
}
}
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 platform = Theme.of(context).platform;
final isMobile =
platform == TargetPlatform.android || platform == TargetPlatform.iOS;
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: 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;
const _SendButton({required this.canSend, required this.pt, required this.onTap});
@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: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack,
width: 36,
height: 36,
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: 16,
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;
}
}
-211
View File
@@ -1,211 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:matrix/matrix.dart';
class ChatInput extends StatefulWidget {
final Room room;
final Event? replyTo;
final VoidCallback? onClearReply;
const ChatInput({
super.key,
required this.room,
this.replyTo,
this.onClearReply,
});
@override
State<ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<ChatInput> {
final _ctrl = TextEditingController();
bool _canSend = false;
@override
void initState() {
super.initState();
_ctrl.addListener(() {
final hasText = _ctrl.text.trim().isNotEmpty;
if (hasText != _canSend) setState(() => _canSend = hasText);
});
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Future<void> _send() async {
final text = _ctrl.text.trim();
if (text.isEmpty) return;
_ctrl.clear();
setState(() => _canSend = false);
await widget.room.sendTextEvent(text, inReplyTo: widget.replyTo);
widget.onClearReply?.call();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDesktop = !kIsWeb &&
(defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS);
// Desktop: taller bar, 4px square corners, bigger button
// Mobile: compact bar, pill shape, smaller button
final fieldRadius = isDesktop ? 4.0 : 20.0;
final btnSize = isDesktop ? 56.0 : 44.0;
final btnRadius = isDesktop ? 6.0 : 12.0;
final iconSize = isDesktop ? 24.0 : 20.0;
final fontSize = isDesktop ? 15.0 : 14.0;
final vPad = isDesktop ? 18.0 : 10.0;
final hPad = isDesktop ? 16.0 : 14.0;
final outerPad = isDesktop
? const EdgeInsets.fromLTRB(12, 10, 12, 12)
: const EdgeInsets.fromLTRB(8, 4, 8, 8);
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.replyTo != null)
_ReplyBar(
event: widget.replyTo!,
onClear: widget.onClearReply ?? () {},
),
Padding(
padding: outerPad,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _ctrl,
minLines: 1,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
style: TextStyle(fontSize: fontSize),
decoration: InputDecoration(
hintText: 'Nachricht...',
hintStyle: TextStyle(fontSize: fontSize),
contentPadding: EdgeInsets.symmetric(
horizontal: hPad,
vertical: vPad,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(fieldRadius),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(fieldRadius),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(fieldRadius),
borderSide: BorderSide.none,
),
filled: true,
fillColor: theme.colorScheme.surfaceContainerHigh,
),
onSubmitted: (_) => _send(),
keyboardType: TextInputType.multiline,
inputFormatters: [
_SendOnEnterFormatter(onSend: _send),
],
),
),
SizedBox(width: isDesktop ? 10 : 8),
GestureDetector(
onTap: _canSend ? _send : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: btnSize,
height: btnSize,
decoration: BoxDecoration(
color: _canSend
? theme.colorScheme.primary
: theme.colorScheme.primary.withAlpha(70),
borderRadius: BorderRadius.circular(btnRadius),
),
child: Center(
child: Icon(
Icons.send_rounded,
size: iconSize,
color: Colors.white,
),
),
),
),
],
),
),
],
),
);
}
}
class _ReplyBar extends StatelessWidget {
final Event event;
final VoidCallback onClear;
const _ReplyBar({required this.event, required this.onClear});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 4),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.dividerColor),
left: BorderSide(color: theme.colorScheme.primary, width: 3),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.senderFromMemoryOrFallback.calcDisplayname(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
Text(
event.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: onClear,
),
],
),
);
}
}
class _SendOnEnterFormatter extends TextInputFormatter {
final VoidCallback onSend;
_SendOnEnterFormatter({required this.onSend});
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
return newValue;
}
}
-156
View File
@@ -1,156 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/features/chat/chat_input.dart';
import 'package:pyramid/features/chat/chat_provider.dart';
import 'package:pyramid/features/chat/message_bubble.dart';
class ChatPage extends ConsumerStatefulWidget {
final String roomId;
const ChatPage({super.key, required this.roomId});
@override
ConsumerState<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends ConsumerState<ChatPage> {
final _scrollCtrl = ScrollController();
Event? _replyTo;
String get _roomId => Uri.decodeComponent(widget.roomId);
@override
void initState() {
super.initState();
_scrollCtrl.addListener(_onScroll);
}
@override
void dispose() {
_scrollCtrl.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollCtrl.position.pixels >=
_scrollCtrl.position.maxScrollExtent - 300) {
final timeline = ref.read(timelineProvider(_roomId)).valueOrNull;
timeline?.requestHistory();
}
}
@override
Widget build(BuildContext context) {
final room = ref.watch(roomProvider(_roomId));
final timelineAsync = ref.watch(timelineProvider(_roomId));
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/rooms'),
),
title: Text(room?.getLocalizedDisplayname() ?? _roomId),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {},
),
],
),
body: Column(
children: [
Expanded(
child: timelineAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Fehler: $e')),
data: (timeline) => _MessageList(
timeline: timeline,
scrollCtrl: _scrollCtrl,
currentUserId: room?.client.userID ?? '',
onReply: (event) => setState(() => _replyTo = event),
),
),
),
if (room != null)
ChatInput(
room: room,
replyTo: _replyTo,
onClearReply: () => setState(() => _replyTo = null),
),
],
),
);
}
}
class _MessageList extends StatelessWidget {
final Timeline timeline;
final ScrollController scrollCtrl;
final String currentUserId;
final ValueChanged<Event> onReply;
const _MessageList({
required this.timeline,
required this.scrollCtrl,
required this.currentUserId,
required this.onReply,
});
@override
Widget build(BuildContext context) {
final events = timeline.events
.where((e) =>
e.type == EventTypes.Message &&
e.status != EventStatus.error)
.toList();
if (events.isEmpty) {
return const Center(child: Text('Noch keine Nachrichten'));
}
return ListView.builder(
controller: scrollCtrl,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: events.length,
itemBuilder: (context, i) {
final event = events[i];
final isOwn = event.senderId == currentUserId;
final showDate = i == events.length - 1 ||
!_sameDay(event.originServerTs, events[i + 1].originServerTs);
final replyEventId =
event.content.tryGetMap<String, dynamic>('m.relates_to')
?['m.in_reply_to']?['event_id'] as String?;
final replyEvent = replyEventId != null
? timeline.events.firstWhere(
(e) => e.eventId == replyEventId,
orElse: () => event,
)
: null;
return Column(
children: [
if (showDate)
DateSeparator(date: event.originServerTs),
GestureDetector(
onLongPress: () => onReply(event),
child: MessageBubble(
event: event,
isOwn: isOwn,
replyEvent:
replyEvent?.eventId != event.eventId ? replyEvent : null,
),
),
],
);
},
);
}
bool _sameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
}
+135 -10
View File
@@ -1,20 +1,145 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/e2ee_diagnostics.dart';
import 'package:pyramid/core/matrix_client.dart';
// AsyncNotifier so onUpdate can refresh state in-place without recreating the
// timeline (which would reset the loaded event window back to the default 30).
class TimelineNotifier extends FamilyAsyncNotifier<Timeline, String> {
Timeline? _timeline;
StreamSubscription<String>? _roomUpdateSub;
Timer? _redecryptTimer;
@override
Future<Timeline> build(String arg) async {
final roomId = arg;
final client = await ref.watch(matrixClientProvider.future);
final room = client.getRoomById(roomId);
if (room == null) throw Exception('Raum nicht gefunden: $roomId');
_timeline?.cancelSubscriptions();
_roomUpdateSub?.cancel();
_redecryptTimer?.cancel();
final diag = ref.read(e2eeDiagnosticsProvider.notifier);
final timeline = await room.getTimeline(
onUpdate: _onUpdate,
);
_timeline = timeline;
if (timeline.events.length < 20) {
await timeline.requestHistory(historyCount: 60);
}
timeline.requestKeys(onlineKeyBackupOnly: false);
unawaited(_decryptLegacyEvents(client, timeline, roomId, diag));
// Re-decrypt whenever the room updates (e.g. after new keys arrive from
// key requests). Debounced to avoid redundant passes on rapid updates.
_roomUpdateSub = room.onUpdate.stream.listen((_) {
_redecryptTimer?.cancel();
_redecryptTimer = Timer(const Duration(milliseconds: 800), () {
final t = _timeline;
if (t != null) {
unawaited(_decryptLegacyEvents(client, t, roomId, diag));
}
});
});
// Ensure device keys are downloaded for all room members so outbound
// Megolm session key sharing works on the first send.
if (room.encrypted && client.encryptionEnabled) {
unawaited(_preloadDeviceKeys(client, room));
}
ref.onDispose(() {
_roomUpdateSub?.cancel();
_redecryptTimer?.cancel();
_timeline?.cancelSubscriptions();
_timeline = null;
});
return timeline;
}
void _onUpdate() {
final t = _timeline;
if (t != null) state = AsyncData(t);
}
}
final timelineProvider =
FutureProvider.family<Timeline, String>((ref, roomId) async {
final client = await ref.watch(matrixClientProvider.future);
final room = client.getRoomById(roomId);
if (room == null) throw Exception('Raum nicht gefunden: $roomId');
AsyncNotifierProvider.family<TimelineNotifier, Timeline, String>(
TimelineNotifier.new,
);
final timeline = await room.getTimeline(
onUpdate: () => ref.invalidateSelf(),
);
Future<void> _decryptLegacyEvents(
Client client,
Timeline timeline,
String roomId,
E2eeDiagnosticsNotifier diag,
) async {
final enc = client.encryption;
if (enc == null) return;
ref.onDispose(timeline.cancelSubscriptions);
return timeline;
});
try {
await enc.keyManager.loadAllKeysFromRoom(roomId);
} catch (e) {
Logs().e('[ChatProvider] loadAllKeysFromRoom failed', e);
}
if (!enc.enabled) return;
var changed = false;
for (var i = 0; i < timeline.events.length; i++) {
final event = timeline.events[i];
if (event.type != EventTypes.Encrypted) continue;
try {
final decrypted = await enc.decryptRoomEvent(
event,
store: true,
updateType: EventUpdateType.history,
);
if (decrypted.type != EventTypes.Encrypted) {
timeline.events[i] = decrypted;
changed = true;
} else {
// Still encrypted — log for diagnostics
final sessionId = event.content.tryGet<String>('session_id') ?? '';
diag.add(E2eeDiagEntry(
timestamp: DateTime.now(),
roomId: roomId,
eventId: event.eventId,
sessionId: sessionId,
error: decrypted.content.tryGet<String>('body') ?? 'unknown',
));
}
} catch (e) {
final sessionId = event.content.tryGet<String>('session_id') ?? '';
diag.add(E2eeDiagEntry(
timestamp: DateTime.now(),
roomId: roomId,
eventId: event.eventId,
sessionId: sessionId,
error: e.toString(),
));
}
}
// Notify UI of in-place changes only when something actually decrypted
if (changed) timeline.onUpdate?.call();
}
Future<void> _preloadDeviceKeys(Client client, Room room) async {
try {
final members = await room.requestParticipants([Membership.join, Membership.invite]);
final userIds = members.map((m) => m.id).toSet();
await client.updateUserDeviceKeys(additionalUsers: userIds);
} catch (e) {
Logs().w('[ChatProvider] Device key preload failed: $e');
}
}
final roomProvider = Provider.family<Room?, String>((ref, roomId) {
final client = ref.watch(matrixClientProvider).valueOrNull;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+710
View File
@@ -0,0 +1,710 @@
import 'package:flutter/material.dart';
import 'package:pyramid/core/theme.dart';
class EmojiPicker extends StatefulWidget {
final ValueChanged<String> onEmojiSelected;
final VoidCallback onClose;
const EmojiPicker({
super.key,
required this.onEmojiSelected,
required this.onClose,
});
@override
State<EmojiPicker> createState() => _EmojiPickerState();
}
class _EmojiPickerState extends State<EmojiPicker> {
int _categoryIndex = 0;
final _searchCtrl = TextEditingController();
String _query = '';
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final filtered = _query.isEmpty
? _kCategories[_categoryIndex].emojis
: _kCategories
.expand((c) => c.emojis)
.where((e) => e.keywords.any((k) => k.contains(_query.toLowerCase())))
.toList();
return Container(
width: 340,
height: 380,
decoration: BoxDecoration(
color: pt.bg1,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: pt.border),
boxShadow: const [
BoxShadow(color: Color(0x50000000), blurRadius: 24, offset: Offset(0, 8)),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: TextField(
controller: _searchCtrl,
style: TextStyle(color: pt.fg, fontSize: 13),
decoration: InputDecoration(
hintText: 'Emoji suchen…',
hintStyle: TextStyle(color: pt.fgDim, fontSize: 13),
prefixIcon: Icon(Icons.search, size: 16, color: pt.fgDim),
isDense: true,
suffixIcon: _query.isNotEmpty
? IconButton(
icon: Icon(Icons.clear, size: 14, color: pt.fgDim),
onPressed: () {
_searchCtrl.clear();
setState(() => _query = '');
},
)
: null,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
filled: true,
fillColor: pt.bg2,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: pt.accent),
),
),
onChanged: (v) => setState(() => _query = v.trim()),
),
),
// Category tabs (hidden during search)
if (_query.isEmpty)
SizedBox(
height: 36,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 6),
itemCount: _kCategories.length,
itemBuilder: (ctx, i) {
final selected = i == _categoryIndex;
return GestureDetector(
onTap: () => setState(() => _categoryIndex = i),
child: Container(
width: 36,
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
decoration: BoxDecoration(
color: selected ? pt.accentSoft : Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: selected ? pt.accent : Colors.transparent,
),
),
child: Center(
child: Text(
_kCategories[i].icon,
style: const TextStyle(fontSize: 16),
),
),
),
);
},
),
),
Container(height: 1, color: pt.border),
// Emoji grid
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(4),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
childAspectRatio: 1,
),
itemCount: filtered.length,
itemBuilder: (ctx, i) {
final emoji = filtered[i];
return _EmojiCell(
emoji: emoji.char,
tooltip: emoji.keywords.isNotEmpty ? emoji.keywords.first : '',
pt: pt,
onTap: () => widget.onEmojiSelected(emoji.char),
);
},
),
),
],
),
),
);
}
}
class _EmojiCell extends StatefulWidget {
final String emoji;
final String tooltip;
final PyramidTheme pt;
final VoidCallback onTap;
const _EmojiCell({
required this.emoji,
required this.tooltip,
required this.pt,
required this.onTap,
});
@override
State<_EmojiCell> createState() => _EmojiCellState();
}
class _EmojiCellState extends State<_EmojiCell> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
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: 600),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
decoration: BoxDecoration(
color: _hovered ? widget.pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(widget.emoji, style: const TextStyle(fontSize: 20)),
),
),
),
),
);
}
}
// ─── Emoji data ────────────────────────────────────────────────────────────
class _EmojiEntry {
final String char;
final List<String> keywords;
const _EmojiEntry(this.char, this.keywords);
}
class _EmojiCategory {
final String icon;
final String name;
final List<_EmojiEntry> emojis;
const _EmojiCategory(this.icon, this.name, this.emojis);
}
const _kCategories = [
_EmojiCategory('😀', 'Smileys', _kSmileys),
_EmojiCategory('👋', 'Personen', _kPeople),
_EmojiCategory('🐶', 'Natur', _kNature),
_EmojiCategory('🍕', 'Essen', _kFood),
_EmojiCategory('', 'Aktivitäten', _kActivities),
_EmojiCategory('🚗', 'Reise', _kTravel),
_EmojiCategory('💡', 'Objekte', _kObjects),
_EmojiCategory('❤️', 'Symbole', _kSymbols),
];
const _kSmileys = [
_EmojiEntry('😀', ['lächeln', 'grinsen', 'happy', 'smiley']),
_EmojiEntry('😁', ['breit', 'grinsen']),
_EmojiEntry('😂', ['lachen', 'tränen', 'witzig', 'lol']),
_EmojiEntry('🤣', ['rollen', 'lachen', 'rofl']),
_EmojiEntry('😃', ['lächeln', 'glücklich']),
_EmojiEntry('😄', ['lächeln', 'augen']),
_EmojiEntry('😅', ['schwitzen', 'erleichtert']),
_EmojiEntry('😆', ['lachen', 'augen']),
_EmojiEntry('😉', ['zwinkern', 'wink']),
_EmojiEntry('😊', ['schüchtern', 'lächeln', 'rot']),
_EmojiEntry('😋', ['lecker', 'zunge']),
_EmojiEntry('😎', ['cool', 'sonnenbrille']),
_EmojiEntry('😍', ['verliebt', 'herz', 'augen']),
_EmojiEntry('🥰', ['verliebt', 'herzen']),
_EmojiEntry('😘', ['kuss', 'herz']),
_EmojiEntry('😗', ['kuss']),
_EmojiEntry('😚', ['kuss', 'augen']),
_EmojiEntry('😙', ['kuss', 'lächeln']),
_EmojiEntry('🥲', ['lächeln', 'tränen']),
_EmojiEntry('😏', ['verschmitzt', 'smirk']),
_EmojiEntry('😒', ['unzufrieden', 'unamused']),
_EmojiEntry('😞', ['enttäuscht']),
_EmojiEntry('😔', ['nachdenklich', 'traurig']),
_EmojiEntry('😟', ['besorgt']),
_EmojiEntry('😕', ['verwirrt', 'confused']),
_EmojiEntry('🙁', ['leicht', 'traurig']),
_EmojiEntry('☹️', ['traurig', 'frowning']),
_EmojiEntry('😣', ['kämpfend']),
_EmojiEntry('😖', ['verwirrt', 'confounded']),
_EmojiEntry('😫', ['erschöpft', 'tired']),
_EmojiEntry('😩', ['müde', 'weary']),
_EmojiEntry('🥺', ['bitte', 'augen']),
_EmojiEntry('😢', ['weinen', 'cry']),
_EmojiEntry('😭', ['laut', 'weinen', 'sob']),
_EmojiEntry('😤', ['wütend', 'frustriert']),
_EmojiEntry('😠', ['wütend', 'angry']),
_EmojiEntry('😡', ['sehr wütend', 'rage']),
_EmojiEntry('🤬', ['fluchen', 'wütend']),
_EmojiEntry('🤯', ['explodierend', 'schockiert']),
_EmojiEntry('😳', ['errötend', 'flushed']),
_EmojiEntry('🥵', ['heiß', 'hot']),
_EmojiEntry('🥶', ['kalt', 'cold']),
_EmojiEntry('😱', ['schreien', 'angst']),
_EmojiEntry('😨', ['ängstlich', 'fearful']),
_EmojiEntry('😰', ['schwitzen', 'angst']),
_EmojiEntry('😥', ['enttäuscht', 'erleichtert']),
_EmojiEntry('😓', ['schwitzen', 'niedergeschlagen']),
_EmojiEntry('🤗', ['umarmen', 'hug']),
_EmojiEntry('🤔', ['denken', 'thinking']),
_EmojiEntry('🫡', ['salutieren']),
_EmojiEntry('🤭', ['kichern', 'hand']),
_EmojiEntry('🫢', ['schockiert']),
_EmojiEntry('🤫', ['shush', 'flüstern']),
_EmojiEntry('🤥', ['lügen', 'pinocchio']),
_EmojiEntry('😶', ['kein mund', 'sprachlos']),
_EmojiEntry('😐', ['neutral']),
_EmojiEntry('😑', ['ausdruckslos']),
_EmojiEntry('😬', ['zähne', 'grimasse']),
_EmojiEntry('🙄', ['augen', 'genervt']),
_EmojiEntry('😯', ['überrascht', 'staunen']),
_EmojiEntry('😦', ['grimasse', 'frowning']),
_EmojiEntry('😧', ['gequält']),
_EmojiEntry('😮', ['überrascht', 'open mouth']),
_EmojiEntry('😲', ['erstaunt', 'astonished']),
_EmojiEntry('🥱', ['gähnen', 'müde']),
_EmojiEntry('🤤', ['sabbern', 'drooling']),
_EmojiEntry('😴', ['schlafen', 'zzz']),
_EmojiEntry('🤢', ['krank', 'übel']),
_EmojiEntry('🤮', ['erbrechen', 'krank']),
_EmojiEntry('🤧', ['niesen', 'krank']),
_EmojiEntry('😷', ['maske', 'krank']),
_EmojiEntry('🤒', ['fieber', 'krank']),
_EmojiEntry('🤕', ['verletzt', 'verband']),
_EmojiEntry('🤑', ['geld', 'reich']),
_EmojiEntry('🤠', ['cowboy', 'hut']),
_EmojiEntry('🥳', ['feiern', 'party']),
_EmojiEntry('🥸', ['verkleidet']),
_EmojiEntry('😎', ['cool', 'sonnenbrille']),
_EmojiEntry('🤓', ['nerd', 'brille']),
_EmojiEntry('🧐', ['monokel', 'nachdenklich']),
_EmojiEntry('😈', ['teufel', 'böse']),
_EmojiEntry('👿', ['teufel', 'wütend']),
_EmojiEntry('💀', ['tot', 'schädel']),
_EmojiEntry('☠️', ['totenkopf', 'kreuzknochgen']),
_EmojiEntry('👻', ['geist', 'halloween']),
_EmojiEntry('💩', ['häufchen', 'poop']),
_EmojiEntry('🤡', ['clown']),
_EmojiEntry('👾', ['monster', 'spiel']),
_EmojiEntry('🎃', ['halloween', 'kürbis']),
];
const _kPeople = [
_EmojiEntry('👋', ['hallo', 'winken', 'wave']),
_EmojiEntry('🤚', ['hand', 'stopp']),
_EmojiEntry('', ['hand', 'hoch']),
_EmojiEntry('🖐️', ['hand', 'finger']),
_EmojiEntry('👌', ['ok', 'prima']),
_EmojiEntry('🤌', ['finger', 'pinch']),
_EmojiEntry('✌️', ['frieden', 'victory', 'peace']),
_EmojiEntry('🤞', ['finger', 'daumen']),
_EmojiEntry('🤟', ['liebe', 'rock']),
_EmojiEntry('🤘', ['rock', 'metal']),
_EmojiEntry('👈', ['links', 'zeigen']),
_EmojiEntry('👉', ['rechts', 'zeigen']),
_EmojiEntry('👆', ['hoch', 'zeigen']),
_EmojiEntry('👇', ['runter', 'zeigen']),
_EmojiEntry('☝️', ['eins', 'oben']),
_EmojiEntry('👍', ['daumen', 'hoch', 'gut', 'like']),
_EmojiEntry('👎', ['daumen', 'runter', 'schlecht', 'dislike']),
_EmojiEntry('', ['faust', 'punch']),
_EmojiEntry('👊', ['faust', 'schlag']),
_EmojiEntry('🤛', ['faust', 'links']),
_EmojiEntry('🤜', ['faust', 'rechts']),
_EmojiEntry('👏', ['klatschen', 'applaus']),
_EmojiEntry('🙌', ['feiern', 'hände']),
_EmojiEntry('🤝', ['handschlag', 'vereinbarung']),
_EmojiEntry('🙏', ['bitte', 'danke', 'beten']),
_EmojiEntry('✍️', ['schreiben', 'stift']),
_EmojiEntry('💪', ['muskel', 'stark']),
_EmojiEntry('🦵', ['bein']),
_EmojiEntry('🦶', ['fuß']),
_EmojiEntry('👂', ['ohr', 'hören']),
_EmojiEntry('👃', ['nase', 'riechen']),
_EmojiEntry('🧠', ['gehirn', 'denken']),
_EmojiEntry('🦷', ['zahn']),
_EmojiEntry('👀', ['augen', 'schauen']),
_EmojiEntry('👁️', ['auge']),
_EmojiEntry('👅', ['zunge']),
_EmojiEntry('👄', ['lippen', 'kuss']),
_EmojiEntry('💋', ['kuss', 'lippen']),
_EmojiEntry('👶', ['baby']),
_EmojiEntry('🧒', ['kind']),
_EmojiEntry('👦', ['junge']),
_EmojiEntry('👧', ['mädchen']),
_EmojiEntry('🧑', ['person']),
_EmojiEntry('👱', ['blond']),
_EmojiEntry('👩', ['frau']),
_EmojiEntry('👨', ['mann']),
_EmojiEntry('🧓', ['älter']),
_EmojiEntry('👴', ['alter mann']),
_EmojiEntry('👵', ['alte frau']),
_EmojiEntry('🧑‍💻', ['programmierer', 'developer']),
_EmojiEntry('👨‍💻', ['mann', 'programmierer']),
_EmojiEntry('👩‍💻', ['frau', 'programmierin']),
];
const _kNature = [
_EmojiEntry('🐶', ['hund', 'dog']),
_EmojiEntry('🐱', ['katze', 'cat']),
_EmojiEntry('🐭', ['maus', 'mouse']),
_EmojiEntry('🐹', ['hamster']),
_EmojiEntry('🐰', ['hase', 'rabbit']),
_EmojiEntry('🦊', ['fuchs', 'fox']),
_EmojiEntry('🐻', ['bär', 'bear']),
_EmojiEntry('🐼', ['panda']),
_EmojiEntry('🐨', ['koala']),
_EmojiEntry('🐯', ['tiger']),
_EmojiEntry('🦁', ['löwe', 'lion']),
_EmojiEntry('🐮', ['kuh', 'cow']),
_EmojiEntry('🐷', ['schwein', 'pig']),
_EmojiEntry('🐸', ['frosch', 'frog']),
_EmojiEntry('🐵', ['affe', 'monkey']),
_EmojiEntry('🐔', ['huhn', 'chicken']),
_EmojiEntry('🐧', ['pinguin', 'penguin']),
_EmojiEntry('🐦', ['vogel', 'bird']),
_EmojiEntry('🦆', ['ente', 'duck']),
_EmojiEntry('🦅', ['adler', 'eagle']),
_EmojiEntry('🦉', ['eule', 'owl']),
_EmojiEntry('🦇', ['fledermaus', 'bat']),
_EmojiEntry('🐺', ['wolf']),
_EmojiEntry('🐗', ['wildschwein', 'boar']),
_EmojiEntry('🐴', ['pferd', 'horse']),
_EmojiEntry('🦄', ['einhorn', 'unicorn']),
_EmojiEntry('🐝', ['biene', 'bee']),
_EmojiEntry('🐛', ['raupe', 'caterpillar']),
_EmojiEntry('🦋', ['schmetterling', 'butterfly']),
_EmojiEntry('🐌', ['schnecke', 'snail']),
_EmojiEntry('🐞', ['marienkäfer', 'ladybug']),
_EmojiEntry('🐜', ['ameise', 'ant']),
_EmojiEntry('🌸', ['kirschblüte', 'cherry blossom']),
_EmojiEntry('🌺', ['hibiskus']),
_EmojiEntry('🌻', ['sonnenblume', 'sunflower']),
_EmojiEntry('🌹', ['rose']),
_EmojiEntry('🌷', ['tulpe', 'tulip']),
_EmojiEntry('🌼', ['blume', 'flower']),
_EmojiEntry('🌿', ['pflanze', 'plant']),
_EmojiEntry('☘️', ['kleeblatt', 'shamrock']),
_EmojiEntry('🍀', ['vierblättriges', 'glück', 'luck']),
_EmojiEntry('🌲', ['baum', 'tree']),
_EmojiEntry('🌳', ['baum', 'deciduous']),
_EmojiEntry('🌴', ['palme', 'palm']),
_EmojiEntry('🌵', ['kaktus', 'cactus']),
_EmojiEntry('🌾', ['gras', 'getreide']),
_EmojiEntry('🍄', ['pilz', 'mushroom']),
_EmojiEntry('🌊', ['welle', 'wave', 'ozean']),
_EmojiEntry('', ['wolken', 'sonne']),
_EmojiEntry('🌈', ['regenbogen', 'rainbow']),
_EmojiEntry('❄️', ['schnee', 'schneflocke', 'cold']),
_EmojiEntry('', ['stern', 'star']),
_EmojiEntry('🌟', ['stern', 'glitzern']),
_EmojiEntry('', ['funken', 'glitzern', 'sparkle']),
_EmojiEntry('🔥', ['feuer', 'fire', 'heiß']),
_EmojiEntry('🌙', ['mond', 'moon']),
_EmojiEntry('☀️', ['sonne', 'sun']),
];
const _kFood = [
_EmojiEntry('🍎', ['apfel', 'apple']),
_EmojiEntry('🍊', ['orange', 'mandarine']),
_EmojiEntry('🍋', ['zitrone', 'lemon']),
_EmojiEntry('🍇', ['trauben', 'grapes']),
_EmojiEntry('🍓', ['erdbeere', 'strawberry']),
_EmojiEntry('🍒', ['kirsche', 'cherry']),
_EmojiEntry('🍑', ['pfirsich', 'peach']),
_EmojiEntry('🥭', ['mango']),
_EmojiEntry('🍍', ['ananas', 'pineapple']),
_EmojiEntry('🥥', ['kokosnuss', 'coconut']),
_EmojiEntry('🍅', ['tomate', 'tomato']),
_EmojiEntry('🫐', ['blaubeere', 'blueberry']),
_EmojiEntry('🍆', ['aubergine', 'eggplant']),
_EmojiEntry('🥑', ['avocado']),
_EmojiEntry('🫑', ['paprika']),
_EmojiEntry('🌽', ['mais', 'corn']),
_EmojiEntry('🥕', ['karotte', 'carrot']),
_EmojiEntry('🧄', ['knoblauch', 'garlic']),
_EmojiEntry('🧅', ['zwiebel', 'onion']),
_EmojiEntry('🥔', ['kartoffel', 'potato']),
_EmojiEntry('🍞', ['brot', 'bread']),
_EmojiEntry('🥐', ['croissant']),
_EmojiEntry('🧀', ['käse', 'cheese']),
_EmojiEntry('🥚', ['ei', 'egg']),
_EmojiEntry('🍳', ['bratpfanne', 'kochen']),
_EmojiEntry('🥓', ['speck', 'bacon']),
_EmojiEntry('🍗', ['hühnchen', 'chicken']),
_EmojiEntry('🍖', ['fleisch', 'knochen']),
_EmojiEntry('🌭', ['hotdog']),
_EmojiEntry('🍔', ['burger', 'hamburger']),
_EmojiEntry('🍟', ['pommes', 'fries']),
_EmojiEntry('🍕', ['pizza']),
_EmojiEntry('🥪', ['sandwich']),
_EmojiEntry('🌮', ['taco']),
_EmojiEntry('🌯', ['wrap']),
_EmojiEntry('🥗', ['salat', 'salad']),
_EmojiEntry('🍜', ['nudeln', 'noodles', 'ramen']),
_EmojiEntry('🍣', ['sushi']),
_EmojiEntry('🍦', ['eis', 'softeis']),
_EmojiEntry('🎂', ['torte', 'geburtstag']),
_EmojiEntry('🍰', ['kuchen', 'cake']),
_EmojiEntry('🧁', ['muffin', 'cupcake']),
_EmojiEntry('🍩', ['donut']),
_EmojiEntry('🍪', ['keks', 'cookie']),
_EmojiEntry('🍫', ['schokolade', 'chocolate']),
_EmojiEntry('🍬', ['bonbon', 'candy']),
_EmojiEntry('', ['kaffee', 'coffee']),
_EmojiEntry('🍵', ['tee', 'tea']),
_EmojiEntry('🧋', ['bubble tea']),
_EmojiEntry('🥤', ['getränk', 'cup']),
_EmojiEntry('🍺', ['bier', 'beer']),
_EmojiEntry('🍻', ['bier', 'prost', 'cheers']),
_EmojiEntry('🥂', ['sekt', 'cheers', 'toast']),
_EmojiEntry('🍷', ['wein', 'wine']),
_EmojiEntry('🥃', ['whiskey']),
];
const _kActivities = [
_EmojiEntry('', ['fußball', 'soccer']),
_EmojiEntry('🏀', ['basketball']),
_EmojiEntry('🏈', ['american football']),
_EmojiEntry('', ['baseball']),
_EmojiEntry('🎾', ['tennis']),
_EmojiEntry('🏐', ['volleyball']),
_EmojiEntry('🏉', ['rugby']),
_EmojiEntry('🥏', ['frisbee']),
_EmojiEntry('🎱', ['billard', 'pool']),
_EmojiEntry('🏓', ['tischtennis', 'ping pong']),
_EmojiEntry('🏸', ['badminton']),
_EmojiEntry('🥊', ['boxen', 'boxing']),
_EmojiEntry('🥋', ['kampfsport', 'martial arts']),
_EmojiEntry('🥅', ['tor', 'goal']),
_EmojiEntry('', ['golf']),
_EmojiEntry('🎿', ['ski']),
_EmojiEntry('🛷', ['schlitten', 'sled']),
_EmojiEntry('🏆', ['pokal', 'trophy', 'gewonnen']),
_EmojiEntry('🥇', ['gold', 'erster']),
_EmojiEntry('🥈', ['silber', 'zweiter']),
_EmojiEntry('🥉', ['bronze', 'dritter']),
_EmojiEntry('🎮', ['spiel', 'gaming', 'controller']),
_EmojiEntry('🕹️', ['joystick', 'spiel']),
_EmojiEntry('🎲', ['würfel', 'dice']),
_EmojiEntry('🧩', ['puzzle']),
_EmojiEntry('🎯', ['ziel', 'dartscheibe']),
_EmojiEntry('🎳', ['bowling']),
_EmojiEntry('🎪', ['zirkus', 'circus']),
_EmojiEntry('🎨', ['kunst', 'malen', 'art']),
_EmojiEntry('🎭', ['theater', 'drama']),
_EmojiEntry('🎬', ['film', 'klappe', 'movie']),
_EmojiEntry('🎵', ['musik', 'note']),
_EmojiEntry('🎶', ['musik', 'noten']),
_EmojiEntry('🎸', ['gitarre', 'guitar']),
_EmojiEntry('🎹', ['klavier', 'piano']),
_EmojiEntry('🥁', ['schlagzeug', 'drum']),
_EmojiEntry('🎷', ['saxophon']),
_EmojiEntry('🎺', ['trompete', 'trumpet']),
_EmojiEntry('🎻', ['geige', 'violin']),
_EmojiEntry('🎤', ['mikrofon', 'microphone']),
];
const _kTravel = [
_EmojiEntry('🚗', ['auto', 'car']),
_EmojiEntry('🚕', ['taxi']),
_EmojiEntry('🚙', ['suv', 'auto']),
_EmojiEntry('🚌', ['bus']),
_EmojiEntry('🚎', ['trolleybus']),
_EmojiEntry('🏎️', ['rennauto', 'racecar']),
_EmojiEntry('🚓', ['polizei', 'police']),
_EmojiEntry('🚑', ['krankenwagen', 'ambulance']),
_EmojiEntry('🚒', ['feuerwehr', 'fire truck']),
_EmojiEntry('🚐', ['minibus', 'van']),
_EmojiEntry('🛻', ['pickup']),
_EmojiEntry('🚚', ['lieferwagen', 'truck']),
_EmojiEntry('🚛', ['lkw', 'truck']),
_EmojiEntry('🏍️', ['motorrad', 'motorcycle']),
_EmojiEntry('🚲', ['fahrrad', 'bicycle', 'bike']),
_EmojiEntry('🛵', ['roller', 'scooter']),
_EmojiEntry('✈️', ['flugzeug', 'plane', 'fliegen']),
_EmojiEntry('🚀', ['rakete', 'rocket', 'space']),
_EmojiEntry('🛸', ['ufo']),
_EmojiEntry('🚁', ['hubschrauber', 'helicopter']),
_EmojiEntry('', ['segelboot', 'sailboat']),
_EmojiEntry('🚢', ['schiff', 'ship']),
_EmojiEntry('🚂', ['zug', 'train']),
_EmojiEntry('🚄', ['hochgeschwindigkeitszug', 'bullet train']),
_EmojiEntry('🏠', ['haus', 'home']),
_EmojiEntry('🏡', ['haus', 'garten']),
_EmojiEntry('🏢', ['büro', 'office']),
_EmojiEntry('🏰', ['schloss', 'castle']),
_EmojiEntry('🗼', ['turm', 'eiffelturm']),
_EmojiEntry('🗽', ['freiheitsstatue']),
_EmojiEntry('🌍', ['erde', 'europa', 'africa']),
_EmojiEntry('🌎', ['erde', 'americas']),
_EmojiEntry('🌏', ['erde', 'asia']),
_EmojiEntry('🗺️', ['karte', 'map']),
_EmojiEntry('🧭', ['kompass', 'compass']),
_EmojiEntry('', ['zelt', 'camping']),
_EmojiEntry('🏖️', ['strand', 'beach']),
_EmojiEntry('🏔️', ['berg', 'mountain']),
_EmojiEntry('🌋', ['vulkan', 'volcano']),
];
const _kObjects = [
_EmojiEntry('💡', ['idee', 'licht', 'light', 'lamp']),
_EmojiEntry('🔦', ['taschenlampe', 'flashlight']),
_EmojiEntry('💻', ['laptop', 'computer']),
_EmojiEntry('🖥️', ['monitor', 'desktop']),
_EmojiEntry('🖨️', ['drucker', 'printer']),
_EmojiEntry('⌨️', ['tastatur', 'keyboard']),
_EmojiEntry('🖱️', ['maus', 'mouse']),
_EmojiEntry('📱', ['handy', 'phone', 'smartphone']),
_EmojiEntry('☎️', ['telefon', 'phone']),
_EmojiEntry('📞', ['telefon', 'anruf', 'call']),
_EmojiEntry('📷', ['kamera', 'camera']),
_EmojiEntry('📸', ['foto', 'kamera', 'selfie']),
_EmojiEntry('📹', ['video', 'kamera']),
_EmojiEntry('🎥', ['film', 'kamera', 'movie']),
_EmojiEntry('📺', ['tv', 'fernseher', 'television']),
_EmojiEntry('📻', ['radio']),
_EmojiEntry('🎙️', ['mikrofon', 'microphone']),
_EmojiEntry('📡', ['satellit', 'satellite']),
_EmojiEntry('🔋', ['batterie', 'battery']),
_EmojiEntry('🔌', ['stecker', 'plug']),
_EmojiEntry('💾', ['diskette', 'speichern', 'save']),
_EmojiEntry('💿', ['cd', 'disk']),
_EmojiEntry('📀', ['dvd', 'disc']),
_EmojiEntry('📁', ['ordner', 'folder']),
_EmojiEntry('📂', ['ordner', 'offen']),
_EmojiEntry('📄', ['datei', 'dokument', 'file']),
_EmojiEntry('📃', ['seite', 'dokument']),
_EmojiEntry('📋', ['zwischenablage', 'clipboard']),
_EmojiEntry('📊', ['grafik', 'diagramm', 'chart']),
_EmojiEntry('📈', ['aufwärts', 'wachstum', 'chart']),
_EmojiEntry('📉', ['abwärts', 'rückgang', 'chart']),
_EmojiEntry('📌', ['pin', 'nadel']),
_EmojiEntry('📍', ['ort', 'pin', 'location']),
_EmojiEntry('✏️', ['stift', 'pen']),
_EmojiEntry('✒️', ['füllfeder', 'pen']),
_EmojiEntry('🖊️', ['kugelschreiber', 'pen']),
_EmojiEntry('📝', ['notiz', 'memo', 'schreiben']),
_EmojiEntry('📚', ['bücher', 'books']),
_EmojiEntry('📖', ['buch', 'lesen']),
_EmojiEntry('🔑', ['schlüssel', 'key']),
_EmojiEntry('🔒', ['schloss', 'lock']),
_EmojiEntry('🔓', ['offen', 'unlock']),
_EmojiEntry('🔨', ['hammer']),
_EmojiEntry('🪛', ['schraubenzieher']),
_EmojiEntry('⚙️', ['zahnrad', 'einstellungen', 'settings']),
_EmojiEntry('🧲', ['magnet']),
_EmojiEntry('🔭', ['teleskop', 'telescope']),
_EmojiEntry('🔬', ['mikroskop', 'microscope']),
_EmojiEntry('💊', ['pille', 'medikament']),
_EmojiEntry('🩺', ['stethoskop', 'arzt']),
_EmojiEntry('🧪', ['reagenzglas', 'experiment']),
_EmojiEntry('💰', ['geld', 'tasche', 'money']),
_EmojiEntry('💳', ['karte', 'kreditkarte']),
_EmojiEntry('🎁', ['geschenk', 'present']),
_EmojiEntry('🎀', ['schleife', 'ribbon']),
_EmojiEntry('🧨', ['feuerwerk', 'cracker']),
_EmojiEntry('🎉', ['feier', 'party', 'celebration']),
_EmojiEntry('🎊', ['konfetti', 'party']),
_EmojiEntry('🏮', ['laterne', 'lantern']),
_EmojiEntry('', ['uhr', 'watch', 'zeit']),
_EmojiEntry('📅', ['kalender', 'calendar']),
_EmojiEntry('', ['wecker', 'alarm']),
_EmojiEntry('', ['sanduhr', 'hourglass']),
];
const _kSymbols = [
_EmojiEntry('❤️', ['herz', 'liebe', 'love', 'heart']),
_EmojiEntry('🧡', ['orange', 'herz']),
_EmojiEntry('💛', ['gelb', 'herz']),
_EmojiEntry('💚', ['grün', 'herz']),
_EmojiEntry('💙', ['blau', 'herz']),
_EmojiEntry('💜', ['lila', 'herz']),
_EmojiEntry('🖤', ['schwarz', 'herz']),
_EmojiEntry('🤍', ['weiß', 'herz']),
_EmojiEntry('🤎', ['braun', 'herz']),
_EmojiEntry('💔', ['gebrochenes herz', 'broken heart']),
_EmojiEntry('❣️', ['herz', 'ausrufezeichen']),
_EmojiEntry('💕', ['zwei herzen', 'love']),
_EmojiEntry('💞', ['herzen', 'drehend']),
_EmojiEntry('💓', ['herz', 'schlagen']),
_EmojiEntry('💗', ['herz', 'wachsend']),
_EmojiEntry('💖', ['herz', 'glitzer']),
_EmojiEntry('💝', ['herz', 'schleife']),
_EmojiEntry('💘', ['herz', 'pfeil', 'cupid']),
_EmojiEntry('💟', ['herz', 'dekoration']),
_EmojiEntry('☮️', ['frieden', 'peace']),
_EmojiEntry('✝️', ['kreuz', 'christian']),
_EmojiEntry('☯️', ['yin yang']),
_EmojiEntry('🔴', ['rot', 'kreis', 'red']),
_EmojiEntry('🟠', ['orange', 'kreis']),
_EmojiEntry('🟡', ['gelb', 'kreis']),
_EmojiEntry('🟢', ['grün', 'kreis']),
_EmojiEntry('🔵', ['blau', 'kreis']),
_EmojiEntry('🟣', ['lila', 'kreis']),
_EmojiEntry('', ['schwarz', 'kreis']),
_EmojiEntry('', ['weiß', 'kreis']),
_EmojiEntry('🟤', ['braun', 'kreis']),
_EmojiEntry('🔺', ['rot', 'dreieck']),
_EmojiEntry('🔻', ['rot', 'dreieck', 'runter']),
_EmojiEntry('♾️', ['unendlich', 'infinity']),
_EmojiEntry('', ['haken', 'check', 'ok', 'ja']),
_EmojiEntry('', ['x', 'nein', 'falsch', 'close']),
_EmojiEntry('', ['x', 'kreuz', 'nein']),
_EmojiEntry('', ['kreis', 'richtig']),
_EmojiEntry('🚫', ['verboten', 'nein']),
_EmojiEntry('⚠️', ['warnung', 'warning']),
_EmojiEntry('🔞', ['verboten', '18+']),
_EmojiEntry('', ['ausrufezeichen', 'wichtig']),
_EmojiEntry('', ['fragezeichen', 'frage']),
_EmojiEntry('💯', ['100', 'perfekt', 'perfect']),
_EmojiEntry('🆗', ['ok', 'schaltfläche']),
_EmojiEntry('🆕', ['neu', 'new']),
_EmojiEntry('🆙', ['up']),
_EmojiEntry('🔝', ['top', 'oben']),
_EmojiEntry('🔛', ['an']),
_EmojiEntry('🔜', ['bald', 'soon']),
_EmojiEntry('🔚', ['ende', 'end']),
_EmojiEntry('♻️', ['recycling', 'wiederverwertung']),
_EmojiEntry('💲', ['dollar', 'geld']),
_EmojiEntry('©️', ['copyright']),
_EmojiEntry('®️', ['eingetragen', 'trademark']),
_EmojiEntry('™️', ['markenzeichen', 'trademark']),
_EmojiEntry('🔔', ['glocke', 'bell', 'benachrichtigung']),
_EmojiEntry('🔕', ['stille', 'no bell']),
_EmojiEntry('🎵', ['musik', 'note']),
_EmojiEntry('🎶', ['musik', 'noten']),
_EmojiEntry('#️⃣', ['raute', 'hash', 'hashtag']),
_EmojiEntry('*️⃣', ['stern', 'asterisk']),
_EmojiEntry('0️⃣', ['null', 'zero']),
_EmojiEntry('1️⃣', ['eins', 'one']),
_EmojiEntry('2️⃣', ['zwei', 'two']),
_EmojiEntry('3️⃣', ['drei', 'three']),
];
+696
View File
@@ -0,0 +1,696 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/utils/gif_favorite_service.dart';
const _giphyApiKey = 'QtEyHWSKVIZJersKNBbJGYIgOhwawjkk';
const _stickerServerUrl = 'https://stickers.steggi-matrix.work';
class GifStickerPicker extends StatefulWidget {
final Room room;
final VoidCallback onClose;
const GifStickerPicker({
super.key,
required this.room,
required this.onClose,
});
@override
State<GifStickerPicker> createState() => _GifStickerPickerState();
}
class _GifStickerPickerState extends State<GifStickerPicker>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _searchController = TextEditingController();
final _customUrlController = TextEditingController();
final _gifScrollController = ScrollController();
final _packSelectorScroll = ScrollController();
List<dynamic> _gifs = [];
bool _gifLoading = false;
int _gifOffset = 0;
int _gifTotal = 0;
String _gifQuery = '';
int? _hoveredGifIndex;
int? _hoveredFavIndex;
static const _kFavsPack = '__favs__';
static const _kMyPack = '__my__';
List<String> _packs = [];
final Map<String, dynamic> _packData = {};
String? _selectedPack;
bool _stickerLoading = false;
bool _packLoading = false;
PyramidTheme? _pt;
List<String> get _allPackTabs {
final tabs = <String>[];
if (GifFavoriteService.stickerFavoritesItems.isNotEmpty) tabs.add(_kFavsPack);
if (GifFavoriteService.userStickerItems.isNotEmpty) tabs.add(_kMyPack);
tabs.addAll(_packs);
return tabs;
}
void _selectDefaultTab() {
final tabs = _allPackTabs;
if (tabs.isNotEmpty && (_selectedPack == null || !tabs.contains(_selectedPack))) {
setState(() => _selectedPack = tabs.first);
}
}
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_gifScrollController.addListener(_onGifScroll);
_loadTrendingGifs();
GifFavoriteService.load().then((_) => setState(() {}));
GifFavoriteService.loadStickers().then((_) {
_selectDefaultTab();
setState(() {});
});
_loadStickerPacks();
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
_customUrlController.dispose();
_gifScrollController.dispose();
_packSelectorScroll.dispose();
super.dispose();
}
void _onGifScroll() {
if (_gifScrollController.position.pixels >=
_gifScrollController.position.maxScrollExtent - 300) {
if (!_gifLoading && _gifOffset < _gifTotal) _loadMoreGifs();
}
}
Future<void> _loadTrendingGifs() async {
setState(() { _gifLoading = true; _gifs = []; _gifOffset = 0; _gifQuery = ''; });
try {
final res = await http.get(Uri.parse(
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=24&offset=0',
));
final data = jsonDecode(res.body);
setState(() {
_gifs = data['data'] ?? [];
_gifTotal = data['pagination']?['total_count'] ?? 0;
_gifOffset = _gifs.length;
_gifLoading = false;
});
} catch (_) {
setState(() => _gifLoading = false);
}
}
Future<void> _searchGifs(String query) async {
if (query.isEmpty) { _loadTrendingGifs(); return; }
setState(() { _gifLoading = true; _gifs = []; _gifOffset = 0; _gifQuery = query; });
try {
final res = await http.get(Uri.parse(
'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=24&offset=0',
));
final data = jsonDecode(res.body);
setState(() {
_gifs = data['data'] ?? [];
_gifTotal = data['pagination']?['total_count'] ?? 0;
_gifOffset = _gifs.length;
_gifLoading = false;
});
} catch (_) {
setState(() => _gifLoading = false);
}
}
Future<void> _loadMoreGifs() async {
if (_gifLoading) return;
setState(() => _gifLoading = true);
try {
final url = _gifQuery.isEmpty
? 'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=24&offset=$_gifOffset'
: 'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(_gifQuery)}&limit=24&offset=$_gifOffset';
final res = await http.get(Uri.parse(url));
final data = jsonDecode(res.body);
setState(() {
_gifs.addAll(data['data'] ?? []);
_gifOffset = _gifs.length;
_gifLoading = false;
});
} catch (_) {
setState(() => _gifLoading = false);
}
}
Future<void> _toggleGifFav(String url, String previewUrl, String title) async {
await GifFavoriteService.toggle(url, previewUrl, title);
setState(() {});
}
Future<void> _loadStickerPacks() async {
setState(() => _stickerLoading = true);
try {
final userId = widget.room.client.userID ?? '';
final res = await http.get(Uri.parse(
'$_stickerServerUrl/packs/index.json?userId=${Uri.encodeComponent(userId)}',
));
final data = jsonDecode(res.body);
final packs = List<String>.from(data['packs'] ?? [])
.where((p) => !p.startsWith('__'))
.toList();
setState(() { _packs = packs; _stickerLoading = false; });
_selectDefaultTab();
if (_selectedPack != null && _selectedPack != _kFavsPack && _selectedPack != _kMyPack) {
await _loadPack(_selectedPack!);
}
} catch (_) {
setState(() => _stickerLoading = false);
}
}
Future<void> _loadPack(String packId) async {
if (_packData.containsKey(packId)) return;
setState(() => _packLoading = true);
try {
final res = await http.get(Uri.parse('$_stickerServerUrl/packs/$packId/pack.json'));
setState(() { _packData[packId] = jsonDecode(res.body); _packLoading = false; });
} catch (_) {
setState(() => _packLoading = false);
}
}
Future<void> _sendGif(String url, String title) async {
widget.onClose();
await widget.room.sendEvent({
'msgtype': 'm.image',
'body': title.isNotEmpty ? title : 'GIF',
'url': url,
'info': {'mimetype': 'image/gif'},
});
}
Future<void> _sendSticker(String mxcUrl, String body, Map<String, dynamic> info) async {
widget.onClose();
await widget.room.sendEvent(
{'body': body, 'url': mxcUrl, 'info': info},
type: EventTypes.Sticker,
);
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
_pt = pt;
return Container(
width: 380,
height: 460,
decoration: BoxDecoration(
color: pt.bg1,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: pt.border),
boxShadow: const [
BoxShadow(color: Color(0x50000000), blurRadius: 24, offset: Offset(0, 8))
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
// Header with tabs
Container(
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: pt.border)),
),
child: TabBar(
controller: _tabController,
indicatorColor: pt.accent,
labelColor: pt.accent,
unselectedLabelColor: pt.fgMuted,
tabs: const [
Tab(icon: Icon(Icons.gif_box_outlined, size: 20)),
Tab(icon: Icon(Icons.sticky_note_2_outlined, size: 20)),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [_buildGifTab(), _buildStickerTab()],
),
),
],
),
),
);
}
// ── GIF Tab ──────────────────────────────────────────────────────────────
Widget _buildGifTab() {
final favorites = GifFavoriteService.cache;
final isSearching = _gifQuery.isNotEmpty;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: TextField(
controller: _searchController,
style: TextStyle(color: _pt!.fg, fontSize: 13),
decoration: InputDecoration(
hintText: 'GIFs suchen…',
hintStyle: TextStyle(color: _pt!.fgDim, fontSize: 13),
prefixIcon: Icon(Icons.search, size: 16, color: _pt!.fgDim),
isDense: true,
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear, size: 16, color: _pt!.fgDim),
onPressed: () {
_searchController.clear();
setState(() {});
_loadTrendingGifs();
},
)
: null,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
filled: true,
fillColor: _pt!.bg2,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _pt!.border)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _pt!.border)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _pt!.accent)),
),
onChanged: (v) => setState(() {}),
onSubmitted: _searchGifs,
),
),
Expanded(
child: _gifLoading && _gifs.isEmpty
? Center(child: CircularProgressIndicator(color: _pt!.accent))
: CustomScrollView(
controller: _gifScrollController,
slivers: [
if (!isSearching && favorites.isNotEmpty) ...[
SliverToBoxAdapter(child: _SectionLabel('❤ Favoriten')),
_gifGrid(
count: favorites.length,
builder: (i) {
final fav = favorites[i];
final url = fav['url'] ?? '';
final preview = fav['preview_url'] ?? url;
final title = fav['title'] ?? 'GIF';
return _GifCell(
imageUrl: preview,
isFavorite: true,
isHovered: _hoveredFavIndex == i,
onHoverChanged: (h) => setState(() => _hoveredFavIndex = h ? i : null),
onTap: () => _sendGif(url, title),
onFavToggle: () => _toggleGifFav(url, preview, title),
);
},
),
SliverToBoxAdapter(child: _SectionLabel('Trending')),
],
_gifGrid(
count: _gifs.length,
builder: (i) {
final gif = _gifs[i];
final preview = gif['images']?['fixed_height_small']?['url'] ?? gif['images']?['fixed_height']?['url'] ?? '';
final full = gif['images']?['fixed_height']?['url'] ?? '';
final title = gif['title'] ?? 'GIF';
final fav = GifFavoriteService.isFavorite(full);
return _GifCell(
imageUrl: preview,
isFavorite: fav,
isHovered: _hoveredGifIndex == i,
onHoverChanged: (h) => setState(() => _hoveredGifIndex = h ? i : null),
onTap: () => _sendGif(full, title),
onFavToggle: () => _toggleGifFav(full, preview, title),
);
},
),
if (_gifLoading)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(12),
child: Center(child: CircularProgressIndicator(
strokeWidth: 2, color: _pt!.accent)),
),
),
],
),
),
],
);
}
SliverGrid _gifGrid({required int count, required Widget Function(int) builder}) =>
SliverGrid(
delegate: SliverChildBuilderDelegate((ctx, i) => builder(i), childCount: count),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisSpacing: 2, mainAxisSpacing: 2, childAspectRatio: 1,
),
);
// ── Sticker Tab ──────────────────────────────────────────────────────────
Widget _buildStickerTab() {
final allTabs = _allPackTabs;
if (_stickerLoading) {
return Center(child: CircularProgressIndicator(color: _pt!.accent));
}
if (allTabs.isEmpty) {
return Center(child: Text('Keine Sticker verfügbar', style: TextStyle(color: _pt!.fgMuted)));
}
final selected = (allTabs.contains(_selectedPack) ? _selectedPack : allTabs.first)!;
return Column(
children: [
SizedBox(
height: 38,
child: ListView.builder(
controller: _packSelectorScroll,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
itemCount: allTabs.length,
itemBuilder: (context, index) {
final tab = allTabs[index];
final isSelected = tab == selected;
String label = tab == _kFavsPack ? '⭐ Favoriten' : tab == _kMyPack ? '🎨 Meine' : (_packData[tab]?['title'] as String? ?? tab);
return Padding(
padding: const EdgeInsets.only(right: 6),
child: GestureDetector(
onTap: () async {
setState(() => _selectedPack = tab);
if (tab != _kFavsPack && tab != _kMyPack) await _loadPack(tab);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: isSelected ? _pt!.accentSoft : Colors.transparent,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected ? _pt!.accent : _pt!.border,
),
),
child: Text(label,
style: TextStyle(
fontSize: 11,
color: isSelected ? _pt!.accent : _pt!.fgMuted,
)),
),
),
);
},
),
),
Container(height: 1, color: _pt!.border),
Expanded(
child: selected == _kFavsPack
? _buildStickerGrid(GifFavoriteService.stickerFavoritesItems)
: selected == _kMyPack
? _buildStickerGrid(GifFavoriteService.userStickerItems, isMyStickers: true)
: _buildPackGrid(selected),
),
],
);
}
Widget _buildStickerGrid(List<Map<String, dynamic>> items, {bool isMyStickers = false}) {
if (items.isEmpty) {
return Center(child: Text('Keine Sticker', style: TextStyle(color: _pt!.fgMuted)));
}
return GridView.builder(
padding: const EdgeInsets.all(4),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, crossAxisSpacing: 3, mainAxisSpacing: 3,
),
itemCount: items.length,
itemBuilder: (ctx, i) {
final s = items[i];
final piUrl = s['url'] as String? ?? '';
final sendMxc = (s['mxc'] as String?)?.isNotEmpty == true
? s['mxc'] as String
: (s['mxc_source'] as String? ?? '');
final mxcSource = s['mxc_source'] as String? ?? '';
final title = s['title'] as String? ?? 'Sticker';
final info = {'mimetype': s['mimetype'] ?? 'image/webp'};
final isUserSticker = s['user_sticker'] == true;
return _StickerCell(
piUrl: piUrl,
isFavorited: s['favorited'] == true,
showHeart: isMyStickers && isUserSticker,
onTap: () => _sendSticker(sendMxc, title, info),
onToggleFavorite: (isMyStickers && isUserSticker)
? () async {
final newFav = !(s['favorited'] == true);
await GifFavoriteService.setUserStickerFavorited(mxcSource, newFav);
if (mounted) setState(() {});
}
: null,
);
},
);
}
Widget _buildPackGrid(String packId) {
if (_packLoading) {
return Center(child: CircularProgressIndicator(color: _pt!.accent));
}
final stickers = (_packData[packId]?['stickers'] as List<dynamic>?) ?? [];
if (stickers.isEmpty) {
return Center(child: Text('Leer', style: TextStyle(color: _pt!.fgMuted)));
}
return GridView.builder(
padding: const EdgeInsets.all(4),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, crossAxisSpacing: 3, mainAxisSpacing: 3,
),
itemCount: stickers.length,
itemBuilder: (ctx, i) {
final s = stickers[i];
final id = s['id']?.toString() ?? '';
final thumbUrl = '$_stickerServerUrl/packs/$packId/${id}_thumb.png';
final mxcUrl = (s['url'] as String?) ?? '';
final body = (s['body'] as String?) ?? '';
final mimetype = (s['info']?['mimetype'] as String?) ?? 'image/webp';
final info = Map<String, dynamic>.from(s['info'] as Map? ?? {});
final isFav = GifFavoriteService.isStickerFavorite(mxcUrl);
return GestureDetector(
onTap: () => _sendSticker(mxcUrl, body, info),
onLongPressStart: (details) {
final dx = details.globalPosition.dx;
final dy = details.globalPosition.dy;
final screen = MediaQuery.sizeOf(context);
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
dx, dy, screen.width - dx, screen.height - dy,
),
items: [
PopupMenuItem(
value: 'fav',
child: Row(children: [
Icon(
isFav ? Icons.favorite_border : Icons.favorite,
size: 16,
color: isFav ? _pt!.fgMuted : Colors.red,
),
const SizedBox(width: 8),
Text(
isFav ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen',
style: TextStyle(color: _pt!.fg, fontSize: 14),
),
]),
),
],
).then((val) async {
if (val == 'fav') {
await GifFavoriteService.togglePackStickerFavorite(
mxcUrl: mxcUrl,
thumbUrl: thumbUrl,
title: body,
mimetype: mimetype,
);
if (mounted) setState(() {});
}
});
},
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
thumbUrl,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Container(color: _pt!.bg2),
),
),
);
},
);
}
}
class _StickerCell extends StatefulWidget {
final String piUrl;
final bool isFavorited;
final bool showHeart;
final VoidCallback onTap;
final Future<void> Function()? onToggleFavorite;
const _StickerCell({
required this.piUrl,
required this.isFavorited,
required this.showHeart,
required this.onTap,
this.onToggleFavorite,
});
@override
State<_StickerCell> createState() => _StickerCellState();
}
class _StickerCellState extends State<_StickerCell> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final showOverlay = widget.showHeart && (_hovered || widget.isFavorited);
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
widget.piUrl,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Container(color: pt.bg2),
),
if (showOverlay)
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: widget.onToggleFavorite,
behavior: HitTestBehavior.opaque,
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: Icon(
widget.isFavorited ? Icons.favorite : Icons.favorite_border,
color: widget.isFavorited ? Colors.red : Colors.white,
size: 13,
),
),
),
),
],
),
),
),
);
}
}
class _SectionLabel extends StatelessWidget {
final String title;
const _SectionLabel(this.title);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(title,
style: TextStyle(color: PyramidColors.fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6)),
);
}
}
class _GifCell extends StatelessWidget {
final String imageUrl;
final bool isFavorite;
final bool isHovered;
final ValueChanged<bool> onHoverChanged;
final VoidCallback onTap;
final VoidCallback onFavToggle;
const _GifCell({
required this.imageUrl,
required this.isFavorite,
required this.isHovered,
required this.onHoverChanged,
required this.onTap,
required this.onFavToggle,
});
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => onHoverChanged(true),
onExit: (_) => onHoverChanged(false),
child: GestureDetector(
onTap: onTap,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
fit: StackFit.expand,
children: [
Image.network(imageUrl, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: PyramidTheme.of(context).bg3)),
if (isHovered || isFavorite)
Positioned(
top: 4, right: 4,
child: GestureDetector(
onTap: onFavToggle,
behavior: HitTestBehavior.opaque,
child: Container(
width: 22, height: 22,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.red : Colors.white,
size: 13,
),
),
),
),
],
),
),
),
);
}
}
+904
View File
@@ -0,0 +1,904 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/media_viewer.dart';
// ─── Audio Player ─────────────────────────────────────────────────────────────
//
// isVoice=true → compact row (Sprachnachricht in Chat)
// isVoice=false → defaultBars card mit Waveform (Audiodatei)
class PyramidAudioPlayer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
final bool isVoice;
const PyramidAudioPlayer({
super.key,
required this.bytes,
required this.filename,
required this.pt,
this.isVoice = false,
});
@override
State<PyramidAudioPlayer> createState() => _PyramidAudioPlayerState();
}
class _PyramidAudioPlayerState extends State<PyramidAudioPlayer> {
late final Player _player;
bool _loading = true;
double _speed = 1.0;
static const _speeds = [1.0, 1.5, 2.0];
@override
void initState() {
super.initState();
_player = Player();
_init();
}
Future<void> _init() async {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'ogg';
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/pyr_audio_${widget.bytes.hashCode}.$ext');
await file.writeAsBytes(widget.bytes);
await _player.open(Media('file://${file.path}'), play: false);
if (mounted) setState(() => _loading = false);
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60);
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
void _seek(Duration to) => _player.seek(to);
void _skipBack() => _player.seek(_player.state.position - const Duration(seconds: 10));
void _skipForward() => _player.seek(_player.state.position + const Duration(seconds: 10));
void _setSpeed(double s) { setState(() => _speed = s); _player.setRate(s); }
@override
Widget build(BuildContext context) {
final pt = widget.pt;
if (_loading) return _buildLoading(pt);
return StreamBuilder<bool>(
stream: _player.stream.playing,
builder: (_, playSnap) => StreamBuilder<Duration>(
stream: _player.stream.position,
builder: (_, posSnap) => StreamBuilder<Duration>(
stream: _player.stream.duration,
builder: (_, durSnap) => StreamBuilder<double>(
stream: _player.stream.volume,
builder: (_, volSnap) {
final playing = playSnap.data ?? false;
final pos = posSnap.data ?? Duration.zero;
final dur = durSnap.data ?? Duration.zero;
final vol = (volSnap.data ?? 100.0) / 100.0;
return widget.isVoice
? _buildCompact(pt, playing, pos, dur)
: _buildDefaultBars(pt, playing, pos, dur, vol);
},
),
),
),
);
}
Widget _buildLoading(PyramidTheme pt) => Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
constraints: const BoxConstraints(maxWidth: 340),
decoration: BoxDecoration(
color: pt.bg1, border: Border.all(color: pt.border),
borderRadius: BorderRadius.circular(pt.rLg),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
SizedBox(width: 32, height: 32,
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
const SizedBox(width: 12),
Text('Lade…', style: TextStyle(color: pt.fgMuted, fontSize: 12)),
]),
);
// ── Compact: Sprachnachricht ───────────────────────────────────────────────
Widget _buildCompact(PyramidTheme pt, bool playing, Duration pos, Duration dur) {
final progress = dur.inMilliseconds == 0
? 0.0 : pos.inMilliseconds / dur.inMilliseconds;
final title = widget.filename.contains('.')
? widget.filename.split('.').first : widget.filename;
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
padding: const EdgeInsets.fromLTRB(12, 12, 14, 12),
constraints: const BoxConstraints(maxWidth: 340),
decoration: BoxDecoration(
color: pt.bg1, border: Border.all(color: pt.border),
borderRadius: BorderRadius.circular(pt.rLg),
),
child: Row(children: [
_AArtwork(size: 36, accent: pt.accent),
const SizedBox(width: 10),
Flexible(child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
Text('Sprachnachricht', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
],
)),
const SizedBox(width: 10),
_APlayButton(playing: playing, onTap: () => playing ? _player.pause() : _player.play(),
size: 32, accent: pt.accent, accentGlow: pt.accentGlow),
const SizedBox(width: 10),
Text(_fmt(pos), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)),
const SizedBox(width: 8),
Expanded(child: _AScrubber(progress: progress, accent: pt.accent, track: pt.bg3,
onSeek: (f) => _seek(Duration(milliseconds: (dur.inMilliseconds * f).round())))),
const SizedBox(width: 8),
Text(_fmt(dur), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)),
]),
);
}
// ── DefaultBars: Audiodatei mit Waveform ───────────────────────────────────
Widget _buildDefaultBars(PyramidTheme pt, bool playing, Duration pos, Duration dur, double vol) {
final progress = dur.inMilliseconds == 0 ? 0.0 : pos.inMilliseconds / dur.inMilliseconds;
final nameParts = widget.filename.split('.');
final title = nameParts.length > 1
? nameParts.sublist(0, nameParts.length - 1).join('.') : widget.filename;
final ext = nameParts.length > 1 ? nameParts.last.toUpperCase() : 'AUDIO';
final remaining = pos <= dur ? dur - pos : Duration.zero;
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
constraints: const BoxConstraints(maxWidth: 380),
decoration: BoxDecoration(
color: pt.bg1, border: Border.all(color: pt.border),
borderRadius: BorderRadius.circular(pt.rLg),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
// ── Header ───────────────────────────────────────────────────────────
Row(children: [
_AArtwork(size: 48, accent: pt.accent),
const SizedBox(width: 14),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
Text(ext, style: TextStyle(color: pt.fgMuted, fontSize: 12)),
],
)),
]),
const SizedBox(height: 14),
// ── Waveform ─────────────────────────────────────────────────────────
LayoutBuilder(builder: (_, c) => GestureDetector(
onTapDown: (d) {
final f = (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0);
_seek(Duration(milliseconds: (dur.inMilliseconds * f).round()));
},
onPanUpdate: (d) {
final f = (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0);
_seek(Duration(milliseconds: (dur.inMilliseconds * f).round()));
},
child: SizedBox(height: 40, child: CustomPaint(
painter: _ChunkyBarsPainter(
progress: progress,
played: pt.accent, unplayed: pt.bg3, cursor: pt.fg,
),
size: Size.infinite,
)),
)),
const SizedBox(height: 8),
// ── Zeitanzeige ──────────────────────────────────────────────────────
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(_fmt(pos), style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)),
Text('${_fmt(remaining)}', style: TextStyle(color: pt.fgDim, fontSize: 11, letterSpacing: 0.3)),
]),
const SizedBox(height: 14),
// ── Steuerung ────────────────────────────────────────────────────────
Row(children: [
Icon(Icons.volume_up_outlined, size: 16, color: pt.fgMuted),
const SizedBox(width: 6),
SizedBox(width: 64, child: _AMiniBar(value: vol, track: pt.bg3, fill: pt.fgMuted)),
const Spacer(),
_AIconBtn(icon: Icons.replay_10, color: pt.fgMuted, rSm: pt.rSm, onTap: _skipBack),
const SizedBox(width: 4),
_APlayButton(
playing: playing,
onTap: () => playing ? _player.pause() : _player.play(),
size: 40, accent: pt.accent, accentGlow: pt.accentGlow,
),
const SizedBox(width: 4),
_AIconBtn(icon: Icons.forward_10, color: pt.fgMuted, rSm: pt.rSm, onTap: _skipForward),
const Spacer(),
..._speeds.map((s) => _ASpeedPill(
label: '${s == s.toInt() ? s.toInt() : s}x',
active: _speed == s,
accent: pt.accent, accentSoft: pt.accentSoft,
bg: pt.bg2, fgMuted: pt.fgMuted, rSm: pt.rSm,
onTap: () => _setSpeed(s),
)),
]),
]),
);
}
}
// ─── Audio: Bausteine ────────────────────────────────────────────────────────
class _AArtwork extends StatelessWidget {
final double size;
final Color accent;
const _AArtwork({required this.size, required this.accent});
@override
Widget build(BuildContext context) {
final hsl = HSLColor.fromColor(accent);
final c1 = hsl.withLightness((hsl.lightness - 0.08).clamp(0.0, 1.0)).toColor();
final c2 = hsl.withHue((hsl.hue + 30) % 360)
.withLightness((hsl.lightness - 0.18).clamp(0.0, 1.0)).toColor();
return Container(
width: size, height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(size * 0.3),
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [c1, c2]),
),
child: Icon(Icons.music_note, color: Colors.white.withOpacity(0.85), size: size * 0.48),
);
}
}
class _APlayButton extends StatelessWidget {
final bool playing;
final VoidCallback? onTap;
final double size;
final Color accent, accentGlow;
const _APlayButton({required this.playing, this.onTap, required this.size,
required this.accent, required this.accentGlow});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: size, height: size,
decoration: BoxDecoration(
color: accent, shape: BoxShape.circle,
boxShadow: [BoxShadow(color: accentGlow, blurRadius: 16, offset: const Offset(0, 4))],
),
child: Icon(playing ? Icons.pause : Icons.play_arrow,
color: PyramidColors.accentFg, size: size * 0.48),
),
);
}
}
class _AIconBtn extends StatelessWidget {
final IconData icon;
final Color color;
final double rSm;
final VoidCallback? onTap;
const _AIconBtn({required this.icon, required this.color, required this.rSm, this.onTap});
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(rSm),
child: SizedBox(width: 32, height: 32,
child: Icon(icon, size: 18, color: color)),
);
}
class _ASpeedPill extends StatelessWidget {
final String label;
final bool active;
final VoidCallback? onTap;
final Color accent, accentSoft, bg, fgMuted;
final double rSm;
const _ASpeedPill({required this.label, this.active = false, this.onTap,
required this.accent, required this.accentSoft, required this.bg,
required this.fgMuted, required this.rSm});
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: active ? accentSoft : bg,
borderRadius: BorderRadius.circular(rSm),
),
child: Text(label, style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w500,
color: active ? accent : fgMuted,
letterSpacing: 0.2,
)),
),
);
}
class _AScrubber extends StatelessWidget {
final double progress;
final Color accent, track;
final ValueChanged<double>? onSeek;
const _AScrubber({required this.progress, required this.accent, required this.track, this.onSeek});
@override
Widget build(BuildContext context) => LayoutBuilder(builder: (_, c) => GestureDetector(
onTapDown: (d) => onSeek?.call((d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)),
onPanUpdate: (d) => onSeek?.call((d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)),
child: SizedBox(height: 14, child: Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(color: track, borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(
widthFactor: progress,
child: Container(height: 3, decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2))),
),
])),
));
}
class _AMiniBar extends StatelessWidget {
final double value;
final Color track, fill;
const _AMiniBar({required this.value, required this.track, required this.fill});
@override
Widget build(BuildContext context) => Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(color: track, borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(
widthFactor: value.clamp(0.0, 1.0),
child: Container(height: 3, decoration: BoxDecoration(color: fill, borderRadius: BorderRadius.circular(2))),
),
]);
}
class _ChunkyBarsPainter extends CustomPainter {
final double progress;
final Color played, unplayed, cursor;
static const int _count = 56;
static final List<double> _heights = _seed();
static List<double> _seed() {
final r = math.Random(7);
return List.generate(_count, (i) {
final env = math.sin((i / _count) * math.pi) * 0.6 + 0.4;
return 0.2 + r.nextDouble() * 0.8 * env;
});
}
const _ChunkyBarsPainter({required this.progress, required this.played,
required this.unplayed, required this.cursor});
@override
void paint(Canvas canvas, Size s) {
const gap = 2.0;
final barW = (s.width - gap * (_count - 1)) / _count;
final cur = (progress * _count).floor();
for (int i = 0; i < _count; i++) {
final h = _heights[i] * s.height;
final x = i * (barW + gap);
final y = (s.height - h) / 2;
final color = i == cur ? cursor : (i < cur ? played : unplayed);
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(x, y, barW, h), const Radius.circular(1)),
Paint()..color = color,
);
}
}
@override
bool shouldRepaint(_ChunkyBarsPainter old) =>
old.progress != progress || old.played != played;
}
// ─── Video Player ─────────────────────────────────────────────────────────────
//
// Variant: minimal — Controls immer sichtbar (overlay auf Video, kein Auto-Hide)
class PyramidVideoPlayer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final PyramidTheme pt;
final String senderName;
const PyramidVideoPlayer({
super.key,
required this.bytes,
required this.filename,
required this.pt,
this.senderName = '',
});
@override
State<PyramidVideoPlayer> createState() => _PyramidVideoPlayerState();
}
class _PyramidVideoPlayerState extends State<PyramidVideoPlayer> {
late final Player _player;
late final VideoController _controller;
bool _loading = true;
double _speed = 1.0;
String _quality = 'Auto';
static const _speeds = [0.5, 1.0, 1.25, 1.5, 2.0];
static const _qualities = ['Auto', '1080p', '720p', '480p', '360p'];
@override
void initState() {
super.initState();
_player = Player();
_controller = VideoController(_player);
_init();
}
Future<void> _init() async {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/pyr_video_${widget.bytes.hashCode}.$ext');
await file.writeAsBytes(widget.bytes);
await _player.open(Media('file://${file.path}'), play: false);
if (mounted) setState(() => _loading = false);
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60);
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
void _openFullscreen() => MediaViewer.show(
context,
senderName: widget.senderName,
filename: widget.filename,
bytes: widget.bytes,
isVideo: true,
);
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final title = widget.filename.contains('.')
? widget.filename.split('.').first : widget.filename;
if (_loading) {
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
constraints: const BoxConstraints(maxWidth: 360),
height: 200,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(pt.rLg),
border: Border.all(color: pt.border),
),
child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
);
}
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
constraints: const BoxConstraints(maxWidth: 420),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(pt.rLg),
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: AspectRatio(
aspectRatio: 16 / 9,
child: StreamBuilder<bool>(
stream: _player.stream.playing,
builder: (_, playSnap) => StreamBuilder<Duration>(
stream: _player.stream.position,
builder: (_, posSnap) => StreamBuilder<Duration>(
stream: _player.stream.duration,
builder: (_, durSnap) {
final playing = playSnap.data ?? false;
final pos = posSnap.data ?? Duration.zero;
final dur = durSnap.data ?? Duration.zero;
final progress = dur.inMilliseconds == 0
? 0.0 : pos.inMilliseconds / dur.inMilliseconds;
return GestureDetector(
onTap: () => playing ? _player.pause() : _player.play(),
child: Stack(fit: StackFit.expand, children: [
// Video
Video(controller: _controller, controls: NoVideoControls),
// Top: Titelleiste mit Farbverlauf
Align(
alignment: Alignment.topCenter,
child: _VTopBar(title: title, onFullscreen: _openFullscreen),
),
// Center: Play-Button wenn pausiert
if (!playing)
const Center(child: _VCenterPlay()),
// Bottom: Steuerleiste (minimal = immer sichtbar)
Align(
alignment: Alignment.bottomCenter,
child: _VBottomBar(
playing: playing, pos: pos, dur: dur,
progress: progress, speed: _speed, quality: _quality,
speeds: _speeds, qualities: _qualities, fmt: _fmt, pt: pt,
onTogglePlay: () => playing ? _player.pause() : _player.play(),
onSeek: (d) => _player.seek(d),
onSkipBack: () => _player.seek(pos - const Duration(seconds: 10)),
onSkipForward: () => _player.seek(pos + const Duration(seconds: 10)),
onSpeed: (s) { setState(() => _speed = s); _player.setRate(s); },
onQuality: (q) => setState(() => _quality = q),
onFullscreen: _openFullscreen,
),
),
]),
);
},
),
),
),
),
);
}
}
// ─── Video: Bausteine ────────────────────────────────────────────────────────
class _VTopBar extends StatelessWidget {
final String title;
final VoidCallback? onFullscreen;
const _VTopBar({required this.title, this.onFullscreen});
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(14, 12, 8, 20),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter, end: Alignment.bottomCenter,
colors: [Color(0x99000000), Colors.transparent],
),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
Expanded(child: Text(title, style: const TextStyle(
color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500,
), overflow: TextOverflow.ellipsis)),
_VOverlayBtn(icon: Icons.fullscreen, onTap: onFullscreen),
]),
);
}
class _VCenterPlay extends StatelessWidget {
const _VCenterPlay();
@override
Widget build(BuildContext context) => Container(
width: 56, height: 56,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.10),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.18)),
),
child: const Icon(Icons.play_arrow, color: Colors.white, size: 26),
);
}
class _VBottomBar extends StatelessWidget {
final bool playing;
final Duration pos, dur;
final double progress, speed;
final String quality;
final List<double> speeds;
final List<String> qualities;
final String Function(Duration) fmt;
final PyramidTheme pt;
final VoidCallback onTogglePlay, onSkipBack, onSkipForward, onFullscreen;
final ValueChanged<Duration> onSeek;
final ValueChanged<double> onSpeed;
final ValueChanged<String> onQuality;
const _VBottomBar({
required this.playing, required this.pos, required this.dur,
required this.progress, required this.speed, required this.quality,
required this.speeds, required this.qualities, required this.fmt,
required this.pt, required this.onTogglePlay, required this.onSeek,
required this.onSkipBack, required this.onSkipForward,
required this.onSpeed, required this.onQuality, required this.onFullscreen,
});
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(12, 16, 12, 12),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter, end: Alignment.topCenter,
colors: [Color(0xCC000000), Colors.transparent],
),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
_VScrubber(progress: progress, dur: dur, onSeek: onSeek, accent: pt.accent),
const SizedBox(height: 8),
Row(children: [
_VPlayMini(playing: playing, onTap: onTogglePlay),
_VOverlayBtn(icon: Icons.replay_10, onTap: onSkipBack),
_VOverlayBtn(icon: Icons.forward_10, onTap: onSkipForward),
const SizedBox(width: 6),
Text('${fmt(pos)} / ${fmt(dur)}',
style: const TextStyle(color: Colors.white70, fontSize: 10, letterSpacing: 0.4)),
const Spacer(),
_VSettingsButton(
speed: speed, quality: quality,
speeds: speeds, qualities: qualities,
onSpeed: onSpeed, onQuality: onQuality,
),
_VOverlayBtn(icon: Icons.fullscreen, onTap: onFullscreen),
]),
]),
);
}
class _VScrubber extends StatelessWidget {
final double progress;
final Duration dur;
final ValueChanged<Duration> onSeek;
final Color accent;
const _VScrubber({required this.progress, required this.dur,
required this.onSeek, required this.accent});
@override
Widget build(BuildContext context) => LayoutBuilder(builder: (_, c) => GestureDetector(
onTapDown: (d) => onSeek(Duration(
milliseconds: (dur.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round())),
onPanUpdate: (d) => onSeek(Duration(
milliseconds: (dur.inMilliseconds * (d.localPosition.dx / c.maxWidth).clamp(0.0, 1.0)).round())),
child: SizedBox(height: 16, child: Stack(alignment: Alignment.centerLeft, children: [
Container(height: 3, decoration: BoxDecoration(
color: Colors.white.withOpacity(0.20), borderRadius: BorderRadius.circular(2))),
FractionallySizedBox(widthFactor: progress, child: Container(height: 3,
decoration: BoxDecoration(color: accent, borderRadius: BorderRadius.circular(2)))),
Positioned(
left: c.maxWidth * progress.clamp(0.0, 1.0) - 5,
child: Container(width: 10, height: 10,
decoration: BoxDecoration(color: accent, shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.4), blurRadius: 0, spreadRadius: 2)])),
),
])),
));
}
class _VPlayMini extends StatelessWidget {
final bool playing;
final VoidCallback? onTap;
const _VPlayMini({required this.playing, this.onTap});
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
width: 34, height: 34,
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
child: Icon(playing ? Icons.pause : Icons.play_arrow,
color: const Color(0xFF0A0A0C), size: 16),
),
);
}
class _VOverlayBtn extends StatelessWidget {
final IconData icon;
final VoidCallback? onTap;
const _VOverlayBtn({required this.icon, this.onTap});
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: SizedBox(width: 32, height: 32,
child: Icon(icon, size: 17, color: Colors.white.withOpacity(0.85))),
);
}
class _VSettingsButton extends StatelessWidget {
final double speed;
final String quality;
final List<double> speeds;
final List<String> qualities;
final ValueChanged<double> onSpeed;
final ValueChanged<String> onQuality;
const _VSettingsButton({required this.speed, required this.quality,
required this.speeds, required this.qualities,
required this.onSpeed, required this.onQuality});
String get _speedLabel {
final s = speed;
return '${s == s.toInt() ? s.toInt() : s}x';
}
@override
Widget build(BuildContext context) => PopupMenuButton<void>(
tooltip: 'Einstellungen',
offset: const Offset(0, -8),
position: PopupMenuPosition.over,
color: const Color(0xF014141A),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.white.withOpacity(0.1)),
),
itemBuilder: (_) => [PopupMenuItem<void>(
enabled: false, padding: EdgeInsets.zero,
child: _VSettingsMenu(
speed: _speedLabel, quality: quality,
speeds: speeds, qualities: qualities,
onSpeed: onSpeed, onQuality: onQuality,
),
)],
child: SizedBox(width: 32, height: 32,
child: Icon(Icons.settings_outlined, size: 16, color: Colors.white.withOpacity(0.85))),
);
}
class _VSettingsMenu extends StatefulWidget {
final String speed, quality;
final List<double> speeds;
final List<String> qualities;
final ValueChanged<double> onSpeed;
final ValueChanged<String> onQuality;
const _VSettingsMenu({required this.speed, required this.quality,
required this.speeds, required this.qualities,
required this.onSpeed, required this.onQuality});
@override
State<_VSettingsMenu> createState() => _VSettingsMenuState();
}
class _VSettingsMenuState extends State<_VSettingsMenu> {
String? _section; // null | 'speed' | 'quality'
static const _fg = Color(0xE5FFFFFF);
static const _dim = Color(0x99FFFFFF);
@override
Widget build(BuildContext context) {
if (_section == null) {
return SizedBox(
width: 190,
child: Column(mainAxisSize: MainAxisSize.min, children: [
_row('Geschwindigkeit', widget.speed, () => setState(() => _section = 'speed')),
_row('Qualität', widget.quality, () => setState(() => _section = 'quality')),
]),
);
}
final isSpeed = _section == 'speed';
return SizedBox(
width: 170,
child: Column(mainAxisSize: MainAxisSize.min, children: [
InkWell(
onTap: () => setState(() => _section = null),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.arrow_back, size: 12, color: _dim),
const SizedBox(width: 8),
Text(isSpeed ? 'GESCHWINDIGKEIT' : 'QUALITÄT',
style: const TextStyle(color: _dim, fontSize: 10, letterSpacing: 0.8)),
]),
),
),
Container(height: 1, color: Colors.white.withOpacity(0.08)),
if (isSpeed)
...widget.speeds.map((s) {
final lbl = '${s == s.toInt() ? s.toInt() : s}x';
return _item(lbl, lbl == widget.speed, () => widget.onSpeed(s));
})
else
...widget.qualities.map((q) => _item(q, q == widget.quality, () => widget.onQuality(q))),
]),
);
}
Widget _row(String label, String value, VoidCallback onTap) => InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(label, style: const TextStyle(color: _fg, fontSize: 12)),
const SizedBox(width: 16),
const Spacer(),
Text(value, style: const TextStyle(color: _dim, fontSize: 11, letterSpacing: 0.3)),
const SizedBox(width: 4),
const Icon(Icons.chevron_right, size: 14, color: _dim),
]),
),
);
Widget _item(String label, bool active, VoidCallback onTap) => InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
SizedBox(width: 18, child: active
? const Icon(Icons.check, size: 14, color: PyramidColors.accentAmber)
: null),
Text(label, style: TextStyle(
color: active ? PyramidColors.accentAmber : _fg, fontSize: 12,
)),
]),
),
);
}
// ─── Fullscreen Video Player ──────────────────────────────────────────────────
// Verwendet AdaptiveVideoControls für native System-Fullscreen-Integration
class FullscreenVideoPlayer extends StatefulWidget {
final Uint8List bytes;
final String filename;
const FullscreenVideoPlayer({super.key, required this.bytes, required this.filename});
@override
State<FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState();
}
class _FullscreenVideoPlayerState extends State<FullscreenVideoPlayer> {
late final Player _player;
late final VideoController _controller;
bool _loading = true;
@override
void initState() {
super.initState();
_player = Player();
_controller = VideoController(_player);
_init();
}
Future<void> _init() async {
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'mp4';
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/pyr_vfull_${widget.bytes.hashCode}.$ext');
await file.writeAsBytes(widget.bytes);
await _player.open(Media('file://${file.path}'), play: true);
if (mounted) setState(() => _loading = false);
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_loading) return const Center(child: CircularProgressIndicator(color: Colors.white));
return Video(controller: _controller, controls: AdaptiveVideoControls);
}
}
+255
View File
@@ -0,0 +1,255 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/media_player.dart';
import 'package:pyramid/utils/gif_favorite_service.dart';
/// Overlay viewer for images, GIFs and videos.
/// Uses a semi-transparent backdrop; tapping outside the content closes it.
class MediaViewer extends StatefulWidget {
final String senderName;
final Uint8List? bytes;
final String? networkUrl;
final String filename;
final bool isVideo;
final Event? event;
const MediaViewer({
super.key,
required this.senderName,
required this.filename,
this.bytes,
this.networkUrl,
this.isVideo = false,
this.event,
});
static Future<void> show(
BuildContext context, {
required String senderName,
required String filename,
Uint8List? bytes,
String? networkUrl,
bool isVideo = false,
Event? event,
}) {
return showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: 'Schließen',
barrierColor: Colors.black.withAlpha(178),
transitionDuration: const Duration(milliseconds: 180),
transitionBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
pageBuilder: (context, animation, secondaryAnimation) => MediaViewer(
senderName: senderName,
filename: filename,
bytes: bytes,
networkUrl: networkUrl,
isVideo: isVideo,
event: event,
),
);
}
@override
State<MediaViewer> createState() => _MediaViewerState();
}
class _MediaViewerState extends State<MediaViewer> {
final _tc = TransformationController();
bool _controlsVisible = true;
bool _saving = false;
bool _savingSticker = false;
@override
void dispose() {
_tc.dispose();
super.dispose();
}
void _toggle() => setState(() => _controlsVisible = !_controlsVisible);
void _resetZoom() => _tc.value = Matrix4.identity();
Future<void> _saveAsSticker() async {
final event = widget.event;
if (event == null || _savingSticker) return;
setState(() => _savingSticker = true);
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final ok = await GifFavoriteService.addImageAsStickerFavorite(event);
messenger?.showSnackBar(SnackBar(
content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'),
duration: const Duration(seconds: 2),
));
} finally {
if (mounted) setState(() => _savingSticker = false);
}
}
Future<void> _save() async {
final b = widget.bytes;
if (b == null || _saving) return;
setState(() => _saving = true);
try {
Directory? dir;
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
dir = await getDownloadsDirectory();
}
dir ??= await getTemporaryDirectory();
final ext = widget.filename.contains('.') ? widget.filename.split('.').last : 'jpg';
final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext';
final file = File('${dir.path}/$name');
await file.writeAsBytes(b);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Gespeichert: ${file.path}'),
duration: const Duration(seconds: 4),
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler: $e')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final isVideo = widget.isVideo && widget.bytes != null;
final size = MediaQuery.sizeOf(context);
final header = AnimatedOpacity(
opacity: _controlsVisible ? 1 : 0,
duration: const Duration(milliseconds: 180),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent],
),
),
child: SafeArea(
bottom: false,
child: Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded, color: Colors.white),
tooltip: 'Schließen',
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.senderName,
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)),
Text(widget.filename,
style: const TextStyle(color: Colors.white60, fontSize: 12),
overflow: TextOverflow.ellipsis),
],
),
),
if (widget.event != null && !isVideo)
IconButton(
onPressed: _savingSticker ? null : _saveAsSticker,
tooltip: 'Als Sticker speichern',
icon: _savingSticker
? const SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.sticky_note_2_outlined, color: Colors.white),
),
if (widget.bytes != null)
IconButton(
onPressed: _saving ? null : _save,
tooltip: 'Speichern',
icon: _saving
? const SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.download_rounded, color: Colors.white),
),
if (!isVideo)
IconButton(
onPressed: _resetZoom,
tooltip: 'Zoom zurücksetzen',
icon: const Icon(Icons.zoom_out_map_rounded, color: Colors.white),
),
],
),
),
),
),
);
// Content area — constrained so the transparent Material outside it lets
// pointer events through to the barrier (which dismisses on tap).
final contentMaxW = (size.width - 48).clamp(200.0, 1200.0);
final contentMaxH = (size.height - 48).clamp(200.0, 900.0);
if (isVideo) {
return Material(
type: MaterialType.transparency,
child: Center(
child: SizedBox(
width: contentMaxW,
height: contentMaxH,
child: Stack(
fit: StackFit.expand,
children: [
FullscreenVideoPlayer(bytes: widget.bytes!, filename: widget.filename),
Positioned(top: 0, left: 0, right: 0, child: header),
],
),
),
),
);
}
Widget img;
if (widget.bytes != null) {
img = Image.memory(widget.bytes!, fit: BoxFit.contain, gaplessPlayback: true);
} else if (widget.networkUrl != null) {
img = Image.network(widget.networkUrl!, fit: BoxFit.contain);
} else {
img = Icon(Icons.broken_image_outlined, size: 64, color: pt.fgDim);
}
return Material(
type: MaterialType.transparency,
child: Center(
child: SizedBox(
width: contentMaxW,
height: contentMaxH,
child: GestureDetector(
onTap: _toggle,
child: Stack(
fit: StackFit.expand,
children: [
InteractiveViewer(
transformationController: _tc,
minScale: 0.5,
maxScale: 10.0,
child: Center(child: img),
),
Positioned(top: 0, left: 0, right: 0, child: header),
],
),
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/chat_provider.dart';
class PinnedMessagesPanel extends ConsumerWidget {
final String roomId;
final VoidCallback onClose;
const PinnedMessagesPanel({
super.key,
required this.roomId,
required this.onClose,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pt = PyramidTheme.of(context);
final room = ref.watch(roomProvider(roomId));
final timeline = ref.watch(timelineProvider(roomId)).valueOrNull;
if (room == null) return const SizedBox.shrink();
final pinnedIds = List<String>.from(
room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
);
final pinned = pinnedIds
.map((id) => timeline?.events.where((e) => e.eventId == id).firstOrNull)
.where((e) => e != null)
.cast<Event>()
.toList();
return Container(
width: 320,
decoration: BoxDecoration(
color: pt.bg1,
border: Border(left: BorderSide(color: pt.border)),
),
child: Column(
children: [
// Header
Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: pt.border)),
),
child: Row(
children: [
Icon(Icons.push_pin_rounded, size: 16, color: pt.fgMuted),
const SizedBox(width: 8),
Expanded(
child: Text(
'Angepinnte Nachrichten',
style: TextStyle(
color: pt.fg,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
GestureDetector(
onTap: onClose,
child: Icon(Icons.close_rounded, size: 18, color: pt.fgDim),
),
],
),
),
// List
Expanded(
child: pinnedIds.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.push_pin_outlined, size: 40, color: pt.fgDim),
const SizedBox(height: 12),
Text(
'Keine angepinnten Nachrichten',
style: TextStyle(color: pt.fgMuted, fontSize: 14),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: pinned.isEmpty ? pinnedIds.length : pinned.length,
itemBuilder: (ctx, i) {
if (pinned.isEmpty) {
return _PinnedPlaceholder(pt: pt);
}
return _PinnedMessageCard(
event: pinned[i],
room: room,
pt: pt,
onUnpin: () => _unpin(room, pinned[i].eventId),
);
},
),
),
],
),
);
}
void _unpin(Room room, String eventId) {
final pinned = List<String>.from(
room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
);
pinned.remove(eventId);
room.setPinnedEvents(pinned).catchError((_) => '');
}
}
class _PinnedPlaceholder extends StatelessWidget {
final PyramidTheme pt;
const _PinnedPlaceholder({required this.pt});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: Text(
'Nachricht wird geladen…',
style: TextStyle(color: pt.fgDim, fontSize: 13, fontStyle: FontStyle.italic),
),
);
}
}
class _PinnedMessageCard extends StatelessWidget {
final Event event;
final Room room;
final PyramidTheme pt;
final VoidCallback onUnpin;
const _PinnedMessageCard({
required this.event,
required this.room,
required this.pt,
required this.onUnpin,
});
@override
Widget build(BuildContext context) {
final sender = event.senderFromMemoryOrFallback.calcDisplayname();
final time = _formatTime(event.originServerTs);
final body = event.body;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.push_pin_rounded, size: 12, color: pt.accent),
const SizedBox(width: 6),
Expanded(
child: Text(
sender,
style: TextStyle(
color: pt.fg,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
Text(time, style: TextStyle(color: pt.fgDim, fontSize: 11)),
const SizedBox(width: 6),
GestureDetector(
onTap: onUnpin,
child: Tooltip(
message: 'Entpinnen',
child: Icon(Icons.push_pin_outlined, size: 14, color: pt.fgDim),
),
),
],
),
const SizedBox(height: 6),
Text(
body,
style: TextStyle(color: pt.fgMuted, fontSize: 13),
maxLines: 5,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
String _formatTime(DateTime t) {
final now = DateTime.now();
final diff = now.difference(t);
if (diff.inDays == 0) {
return '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
}
if (diff.inDays < 7) return 'vor ${diff.inDays}d';
return '${t.day}.${t.month}.${t.year}';
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:shared_preferences/shared_preferences.dart';
class ReactionService {
static const _key = 'recent_reactions';
static const _maxRecent = 6;
static final _defaultReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
static List<String> _recent = [];
static bool _loaded = false;
static Future<void> load() async {
if (_loaded) return;
final prefs = await SharedPreferences.getInstance();
_recent = prefs.getStringList(_key) ?? [];
_loaded = true;
}
static List<String> get quickReactions {
if (_recent.isEmpty) return _defaultReactions;
// Fill up to 6 with defaults not already in recent
final result = List<String>.from(_recent);
for (final d in _defaultReactions) {
if (result.length >= _maxRecent) break;
if (!result.contains(d)) result.add(d);
}
return result.take(_maxRecent).toList();
}
static Future<void> recordUsed(String emoji) async {
await load();
_recent.remove(emoji);
_recent.insert(0, emoji);
if (_recent.length > _maxRecent) _recent = _recent.sublist(0, _maxRecent);
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_key, _recent);
}
}