import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/widgets/mxc_image.dart'; // ─── State ──────────────────────────────────────────────────────────────────── enum _Filter { all, media, people } class _SearchState { final bool loading; final List messageResults; final List roomResults; final String? error; const _SearchState({ this.loading = false, this.messageResults = const [], this.roomResults = const [], this.error, }); _SearchState copyWith({ bool? loading, List? messageResults, List? roomResults, String? error, }) => _SearchState( loading: loading ?? this.loading, messageResults: messageResults ?? this.messageResults, roomResults: roomResults ?? this.roomResults, error: error, ); } // ─── Widget ─────────────────────────────────────────────────────────────────── class SearchModal extends ConsumerStatefulWidget { final VoidCallback onClose; const SearchModal({super.key, required this.onClose}); @override ConsumerState createState() => _SearchModalState(); } class _SearchModalState extends ConsumerState { final _ctrl = TextEditingController(); final _focusNode = FocusNode(); _Filter _filter = _Filter.all; String _query = ''; int _selected = 0; _SearchState _state = const _SearchState(); Timer? _debounce; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus()); } @override void dispose() { _debounce?.cancel(); _ctrl.dispose(); _focusNode.dispose(); super.dispose(); } void _onQueryChanged(String q) { setState(() { _query = q; _selected = 0; }); _debounce?.cancel(); if (q.trim().isEmpty) { setState(() => _state = const _SearchState()); return; } _debounce = Timer(const Duration(milliseconds: 400), () => _runSearch(q.trim())); } Future _runSearch(String q) async { final client = ref.read(matrixClientProvider).valueOrNull; if (client == null) return; setState(() => _state = _state.copyWith(loading: true, error: null)); // Room/people filter: purely local final allRooms = client.rooms.where((r) => !r.isSpace).toList(); final roomResults = _filter == _Filter.media ? [] : allRooms .where((r) { if (_filter == _Filter.people && !r.isDirectChat) return false; if (_filter == _Filter.all && r.isDirectChat) return false; return r.getLocalizedDisplayname().toLowerCase().contains(q.toLowerCase()); }) .take(5) .toList(); // Message/media search: LOCAL over decrypted events. The server-side // /search API can't see E2EE message content (only ciphertext), so it // returns nothing for encrypted rooms — we search the local database // (which holds decrypted events) instead, like Element does. List messageResults = []; if (_filter != _Filter.people) { try { messageResults = await _localMessageSearch(client, q); } catch (e) { setState(() => _state = _state.copyWith( loading: false, error: 'Suche fehlgeschlagen: $e', )); return; } } if (mounted) { setState(() => _state = _SearchState( loading: false, messageResults: messageResults, roomResults: roomResults, )); } } // Scans the local (decrypted) event database across all rooms for messages // whose body matches the query. For the media filter, only media messages // whose filename/caption matches are returned. Future> _localMessageSearch(Client client, String q) async { final db = client.database; final lower = q.toLowerCase(); final media = _filter == _Filter.media; const mediaTypes = {'m.image', 'm.video', 'm.file', 'm.audio'}; const perRoomScanCap = 1500; const totalCap = 60; const chunk = 500; final results = []; for (final room in client.rooms.where((r) => !r.isSpace)) { var start = 0; var scanned = 0; try { while (scanned < perRoomScanCap) { final events = await db.getEventList(room, start: start, limit: chunk); if (events.isEmpty) break; for (final e in events) { if (e.type != EventTypes.Message || e.redacted) continue; final msgtype = e.content.tryGet('msgtype') ?? ''; if (media && !mediaTypes.contains(msgtype)) continue; if (!e.body.toLowerCase().contains(lower)) continue; results.add(e); } start += chunk; scanned += events.length; if (events.length < chunk || results.length >= totalCap) break; } } catch (_) {} if (results.length >= totalCap) break; } results.sort((a, b) => b.originServerTs.compareTo(a.originServerTs)); return results.take(totalCap).toList(); } void _openRoom(String roomId, {String? eventId}) { widget.onClose(); ref.read(activeRoomIdProvider.notifier).state = roomId; ref.read(viewModeProvider.notifier).state = ViewMode.chat; if (eventId != null) { ref.read(pendingJumpEventProvider.notifier).state = eventId; } } int get _totalResults => _state.roomResults.length + _state.messageResults.length; @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final client = ref.watch(matrixClientProvider).valueOrNull; final filters = [ (_Filter.all, 'Alle', Icons.auto_awesome_rounded), (_Filter.media, 'Medien', Icons.image_rounded), (_Filter.people, 'Personen & Räume', Icons.group_rounded), ]; return KeyboardListener( focusNode: FocusNode(), onKeyEvent: (e) { if (e is! KeyDownEvent) return; if (e.logicalKey == LogicalKeyboardKey.escape) widget.onClose(); if (e.logicalKey == LogicalKeyboardKey.arrowDown) { setState(() => _selected = (_selected + 1) % _totalResults.clamp(1, 999)); } if (e.logicalKey == LogicalKeyboardKey.arrowUp) { setState(() => _selected = (_selected - 1).clamp(0, _totalResults - 1)); } if (e.logicalKey == LogicalKeyboardKey.enter) { _activateSelected(client); } }, child: GestureDetector( onTap: widget.onClose, child: Container( color: Colors.black.withAlpha(150), alignment: Alignment.topCenter, padding: const EdgeInsets.only(top: 80), child: GestureDetector( onTap: () {}, child: Container( width: 640, constraints: const BoxConstraints(maxHeight: 560), decoration: BoxDecoration( color: pt.bg1, borderRadius: BorderRadius.circular(pt.rXl), border: Border.all(color: pt.border), boxShadow: [ BoxShadow(color: Colors.black.withAlpha(180), blurRadius: 60, offset: const Offset(0, 20)), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Input _SearchBar(ctrl: _ctrl, focusNode: _focusNode, onChanged: _onQueryChanged, pt: pt), // Filters _FilterRow( filters: filters, active: _filter, pt: pt, onSelect: (f) { setState(() { _filter = f; _selected = 0; }); if (_query.trim().isNotEmpty) _runSearch(_query.trim()); }, ), // Results Flexible(child: _ResultsPane( state: _state, query: _query, filter: _filter, selected: _selected, pt: pt, client: client, onOpenRoom: _openRoom, )), // Footer _Footer( count: _totalResults, loading: _state.loading, pt: pt, ), ], ), ), ), ), ), ); } void _activateSelected(Client? client) { final roomCount = _state.roomResults.length; if (_selected < roomCount) { _openRoom(_state.roomResults[_selected].id); } else { final idx = _selected - roomCount; if (idx < _state.messageResults.length) { final e = _state.messageResults[idx]; final rid = e.roomId; if (rid != null) _openRoom(rid, eventId: e.eventId); } } } } // ─── Search bar ─────────────────────────────────────────────────────────────── class _SearchBar extends StatelessWidget { final TextEditingController ctrl; final FocusNode focusNode; final ValueChanged onChanged; final PyramidTheme pt; const _SearchBar({required this.ctrl, required this.focusNode, required this.onChanged, required this.pt}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))), child: Row( children: [ Icon(Icons.search_rounded, size: 20, color: pt.fgDim), const SizedBox(width: 10), Expanded( child: TextField( controller: ctrl, focusNode: focusNode, style: TextStyle(color: pt.fg, fontSize: 15), cursorColor: pt.accent, decoration: InputDecoration( hintText: 'Nachrichten, Medien, Personen, Räume suchen…', hintStyle: TextStyle(color: pt.fgDim, fontSize: 15), border: InputBorder.none, contentPadding: EdgeInsets.zero, isDense: true, ), onChanged: onChanged, ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(4), border: Border.all(color: pt.border), ), child: Text('esc', style: TextStyle(color: pt.fgMuted, fontSize: 11)), ), ], ), ); } } // ─── Filter row ─────────────────────────────────────────────────────────────── class _FilterRow extends StatelessWidget { final List<(_Filter, String, IconData)> filters; final _Filter active; final PyramidTheme pt; final ValueChanged<_Filter> onSelect; const _FilterRow({required this.filters, required this.active, required this.pt, required this.onSelect}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration(border: Border(bottom: BorderSide(color: pt.border))), child: SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: filters.map((f) => Padding( padding: const EdgeInsets.only(right: 6), child: _Chip(label: f.$2, icon: f.$3, active: active == f.$1, pt: pt, onTap: () => onSelect(f.$1)), )).toList(), ), ), ); } } // ─── Results pane ───────────────────────────────────────────────────────────── class _ResultsPane extends StatelessWidget { final _SearchState state; final String query; final _Filter filter; final int selected; final PyramidTheme pt; final Client? client; final void Function(String roomId, {String? eventId}) onOpenRoom; const _ResultsPane({ required this.state, required this.query, required this.filter, required this.selected, required this.pt, required this.client, required this.onOpenRoom, }); @override Widget build(BuildContext context) { if (query.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.search_rounded, size: 36, color: pt.fgDim), const SizedBox(height: 10), Text('Tippe um zu suchen', style: TextStyle(color: pt.fgMuted, fontSize: 14)), ]), ), ); } if (state.loading) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), ), ); } if (state.error != null) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Text(state.error!, style: TextStyle(color: pt.danger, fontSize: 13)), ), ); } final hasRooms = state.roomResults.isNotEmpty; final hasMessages = state.messageResults.isNotEmpty; if (!hasRooms && !hasMessages) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Text('Keine Ergebnisse für "$query"', style: TextStyle(color: pt.fgMuted, fontSize: 14)), ), ); } int itemIndex = 0; final items = []; if (hasRooms) { items.add(_SectionHeader(label: filter == _Filter.people ? 'Personen & Räume' : 'Räume', pt: pt)); for (final room in state.roomResults) { final idx = itemIndex++; items.add(_RoomItem(room: room, selected: selected == idx, pt: pt, onTap: () => onOpenRoom(room.id))); } } if (hasMessages) { items.add(_SectionHeader(label: filter == _Filter.media ? 'Medien' : 'Nachrichten', pt: pt)); for (final event in state.messageResults) { final idx = itemIndex++; final room = client?.getRoomById(event.roomId ?? '') ?? event.room; items.add(_MessageItem( event: event, room: room, selected: selected == idx, pt: pt, onTap: () => onOpenRoom(event.roomId ?? '', eventId: event.eventId), )); } } return ListView( padding: const EdgeInsets.symmetric(vertical: 4), shrinkWrap: true, children: items, ); } } // ─── Section header ─────────────────────────────────────────────────────────── class _SectionHeader extends StatelessWidget { final String label; final PyramidTheme pt; const _SectionHeader({required this.label, required this.pt}); @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.fromLTRB(14, 10, 14, 4), child: Text(label.toUpperCase(), style: TextStyle(color: pt.fgDim, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.8)), ); } // ─── Room result item ───────────────────────────────────────────────────────── class _RoomItem extends StatefulWidget { final Room room; final bool selected; final PyramidTheme pt; final VoidCallback onTap; const _RoomItem({required this.room, required this.selected, required this.pt, required this.onTap}); @override State<_RoomItem> createState() => _RoomItemState(); } class _RoomItemState extends State<_RoomItem> { bool _hovered = false; Color _colorForName(String n) { const c = [Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFFFF6B9D), Color(0xFF10B981), Color(0xFFF59E0B), Color(0xFF3B82F6)]; return c[n.codeUnits.fold(0, (a, b) => a + b) % c.length]; } @override Widget build(BuildContext context) { final pt = widget.pt; final room = widget.room; final name = room.getLocalizedDisplayname(); final avatarUrl = room.avatar; final isDm = room.isDirectChat; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 100), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), margin: const EdgeInsets.symmetric(horizontal: 6), decoration: BoxDecoration( color: widget.selected || _hovered ? pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(pt.rSm), ), child: Row( children: [ SizedBox(width: 32, height: 32, child: avatarUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(isDm ? 16 : pt.rSm), child: MxcImage(mxcUri: avatarUrl.toString(), client: room.client, width: 32, height: 32), ) : Container( decoration: BoxDecoration( color: _colorForName(name), borderRadius: BorderRadius.circular(isDm ? 16 : pt.rSm), ), child: Center(child: Text(name.isEmpty ? '?' : name[0].toUpperCase(), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14))), ), ), const SizedBox(width: 10), Expanded(child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(isDm ? Icons.person_rounded : Icons.tag_rounded, size: 11, color: pt.fgDim), const SizedBox(width: 3), Expanded(child: Text(name, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis)), ]), if (room.lastEvent != null) Text(room.lastEvent!.body, style: TextStyle(color: pt.fgMuted, fontSize: 12), overflow: TextOverflow.ellipsis, maxLines: 1), ], )), if (room.notificationCount > 0) Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: pt.accent, borderRadius: BorderRadius.circular(999)), child: Text('${room.notificationCount}', style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)), ), ], ), ), ), ); } } // ─── Message result item ────────────────────────────────────────────────────── class _MessageItem extends StatefulWidget { final Event event; final Room? room; final bool selected; final PyramidTheme pt; final VoidCallback onTap; const _MessageItem({required this.event, required this.room, required this.selected, required this.pt, required this.onTap}); @override State<_MessageItem> createState() => _MessageItemState(); } class _MessageItemState extends State<_MessageItem> { bool _hovered = false; String _senderName() { final event = widget.event; final room = widget.room; if (room != null) { final member = room.unsafeGetUserFromMemoryOrFallback(event.senderId); return member.displayName ?? event.senderId.split(':').first.replaceFirst('@', ''); } return event.senderId.split(':').first.replaceFirst('@', ''); } String _timeLabel() { final ts = widget.event.originServerTs; final now = DateTime.now(); final diff = now.difference(ts); if (diff.inDays == 0) return '${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}'; if (diff.inDays < 7) return _weekday(ts.weekday); return '${ts.day}.${ts.month}.${ts.year}'; } String _weekday(int d) => ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'][d - 1]; String _msgBody() { final content = widget.event.content; final msgtype = content.tryGet('msgtype') ?? ''; if (msgtype == 'm.image') return '📷 Bild'; if (msgtype == 'm.video') return '🎬 Video'; if (msgtype == 'm.audio') return '🎵 Audio'; if (msgtype == 'm.file') return '📎 ${content.tryGet('body') ?? 'Datei'}'; return content.tryGet('body') ?? ''; } IconData _roomIcon() { final room = widget.room; if (room == null) return Icons.tag_rounded; return room.isDirectChat ? Icons.person_rounded : Icons.tag_rounded; } bool get _isVisualMedia { final t = widget.event.content.tryGet('msgtype') ?? ''; return t == 'm.image' || t == 'm.video'; } @override Widget build(BuildContext context) { final pt = widget.pt; final roomName = widget.room?.getLocalizedDisplayname() ?? widget.event.roomId ?? ''; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 100), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: widget.selected || _hovered ? pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(pt.rSm), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_isVisualMedia) Padding( padding: const EdgeInsets.only(top: 1, right: 8), child: _SearchThumb(event: widget.event, pt: pt), ) else ...[ Padding( padding: const EdgeInsets.only(top: 2), child: Icon(_roomIcon(), size: 14, color: pt.fgDim), ), const SizedBox(width: 8), ], Expanded(child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Expanded(child: Text(roomName, style: TextStyle(color: pt.fgMuted, fontSize: 11), overflow: TextOverflow.ellipsis)), Text(_timeLabel(), style: TextStyle(color: pt.fgDim, fontSize: 11)), ]), const SizedBox(height: 1), RichText( maxLines: 2, overflow: TextOverflow.ellipsis, text: TextSpan(children: [ TextSpan(text: '${_senderName()}: ', style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600)), TextSpan(text: _msgBody(), style: TextStyle(color: pt.fgMuted, fontSize: 13)), ]), ), ], )), ], ), ), ), ); } } // ─── Search media thumbnail ─────────────────────────────────────────────────── // Loads (and decrypts, if needed) a small thumbnail for image/video results. class _SearchThumb extends StatefulWidget { final Event event; final PyramidTheme pt; const _SearchThumb({required this.event, required this.pt}); @override State<_SearchThumb> createState() => _SearchThumbState(); } class _SearchThumbState extends State<_SearchThumb> { static final Map _cache = {}; Uint8List? _bytes; @override void initState() { super.initState(); _load(); } Future _load() async { final id = widget.event.eventId; if (_cache.containsKey(id)) { setState(() => _bytes = _cache[id]); return; } try { final file = await widget.event .downloadAndDecryptAttachment(getThumbnail: true) .timeout(const Duration(seconds: 10)); _cache[id] = file.bytes; if (mounted) setState(() => _bytes = file.bytes); } catch (_) { _cache[id] = null; } } @override Widget build(BuildContext context) { final pt = widget.pt; const size = 38.0; return ClipRRect( borderRadius: BorderRadius.circular(pt.rSm), child: SizedBox( width: size, height: size, child: _bytes != null ? Image.memory(_bytes!, fit: BoxFit.cover, errorBuilder: (_, _, _) => _placeholder(pt)) : _placeholder(pt), ), ); } Widget _placeholder(PyramidTheme pt) => Container( color: pt.bg3, child: Icon( (widget.event.content.tryGet('msgtype') ?? '') == 'm.video' ? Icons.videocam_rounded : Icons.image_rounded, size: 18, color: pt.fgDim, ), ); } // ─── Shared chips / footer ──────────────────────────────────────────────────── class _Chip extends StatefulWidget { final String label; final IconData icon; final bool active; final PyramidTheme pt; final VoidCallback onTap; const _Chip({required this.label, required this.icon, required this.active, required this.pt, required this.onTap}); @override State<_Chip> createState() => _ChipState(); } class _ChipState extends State<_Chip> { bool _h = false; @override Widget build(BuildContext context) { final pt = widget.pt; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _h = true), onExit: (_) => setState(() => _h = false), child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: widget.active ? pt.accentSoft : _h ? pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(999), border: Border.all(color: widget.active ? pt.accent : pt.border), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(widget.icon, size: 11, color: widget.active ? pt.accent : pt.fgMuted), const SizedBox(width: 4), Text(widget.label, style: TextStyle( color: widget.active ? pt.accent : pt.fgMuted, fontSize: 12, fontWeight: widget.active ? FontWeight.w600 : FontWeight.w400, )), ]), ), ), ); } } class _Footer extends StatelessWidget { final int count; final bool loading; final PyramidTheme pt; const _Footer({required this.count, required this.loading, required this.pt}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( color: pt.bg2, border: Border(top: BorderSide(color: pt.border)), borderRadius: BorderRadius.only(bottomLeft: Radius.circular(pt.rXl), bottomRight: Radius.circular(pt.rXl)), ), child: Row( children: [ _Hint('↑↓', 'navigieren', pt), const SizedBox(width: 16), _Hint('↵', 'öffnen', pt), const Spacer(), if (loading) SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5, color: pt.fgDim)) else Text( '$count Ergebnis${count == 1 ? '' : 'se'}', style: TextStyle(color: pt.fgDim, fontSize: 11), ), ], ), ); } } class _Hint extends StatelessWidget { final String key_; final String label; final PyramidTheme pt; const _Hint(this.key_, this.label, this.pt); @override Widget build(BuildContext context) => Row(children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration(color: pt.bg3, borderRadius: BorderRadius.circular(4), border: Border.all(color: pt.border)), child: Text(key_, style: TextStyle(color: pt.fgMuted, fontSize: 11, fontFamily: 'monospace')), ), const SizedBox(width: 4), Text(label, style: TextStyle(color: pt.fgDim, fontSize: 11)), ]); }