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:
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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']),
|
||||
];
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user