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