Files
pyramid/lib/widgets/search_modal.dart
T
Bernd Steckmeister 99f10c5210 style: doppelte Unterstriche in Closure-Parametern bereinigen
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>
2026-07-03 06:30:40 +02:00

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)),
]);
}