99f10c5210
Dart 3 erlaubt mehrere Wildcard-Parameter mit demselben Namen "_" in einem Scope, daher sind __ / ___ für ungenutzte Callback-Parameter (pageBuilder, errorBuilder etc.) unnötig. Rein mechanische Änderung, keine Verhaltensänderung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
841 lines
29 KiB
Dart
841 lines
29 KiB
Dart
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<Event> messageResults;
|
|
final List<Room> roomResults;
|
|
final String? error;
|
|
|
|
const _SearchState({
|
|
this.loading = false,
|
|
this.messageResults = const [],
|
|
this.roomResults = const [],
|
|
this.error,
|
|
});
|
|
|
|
_SearchState copyWith({
|
|
bool? loading,
|
|
List<Event>? messageResults,
|
|
List<Room>? 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<SearchModal> createState() => _SearchModalState();
|
|
}
|
|
|
|
class _SearchModalState extends ConsumerState<SearchModal> {
|
|
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<void> _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
|
|
? <Room>[]
|
|
: 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<Event> 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<List<Event>> _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 = <Event>[];
|
|
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<String>('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<String> 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 = <Widget>[];
|
|
|
|
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<String>('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<String>('body') ?? 'Datei'}';
|
|
return content.tryGet<String>('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<String>('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<String, Uint8List?> _cache = {};
|
|
Uint8List? _bytes;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _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<String>('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)),
|
|
]);
|
|
}
|