Files
sharedinbox/lib/ui/screens/search_screen.dart
T
Thomas GüttlerandClaude Sonnet 4.6 e3ba18285d refactor: enforce always_use_package_imports across all lib files
Added lint rule to analysis_options.yaml and ran dart fix --apply to convert
125 relative imports in 33 files to package:sharedinbox/... style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:30:59 +02:00

274 lines
7.7 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key, required this.accountId});
final String accountId;
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final _ctrl = TextEditingController();
Timer? _debounce;
_SearchResults? _results;
bool _loading = false;
@override
void dispose() {
_ctrl.dispose();
_debounce?.cancel();
super.dispose();
}
void _onChanged(String value) {
_debounce?.cancel();
if (value.trim().length < 3) {
setState(() => _results = null);
return;
}
_debounce = Timer(
const Duration(milliseconds: 300),
() => _search(value.trim()),
);
}
Future<void> _search(String query) async {
setState(() => _loading = true);
try {
final emailRepo = ref.read(emailRepositoryProvider);
final mailboxRepo = ref.read(mailboxRepositoryProvider);
final ql = query.toLowerCase();
final (allMailboxes, emails, addressEmails) = await (
mailboxRepo.observeMailboxes(widget.accountId).first,
emailRepo.searchEmailsGlobal(widget.accountId, query),
emailRepo.getEmailsByAddress(widget.accountId, query),
).wait;
final matchedMailboxes = allMailboxes
.where((m) => m.name.toLowerCase().contains(ql))
.toList()
..sort(compareMailboxes);
// Collect unique addresses from address-search results where the
// email or display name contains the query.
final seen = <String>{};
final addresses = <(EmailAddress, int)>[];
for (final email in addressEmails) {
for (final addr in [...email.from, ...email.to, ...email.cc]) {
if (seen.contains(addr.email)) continue;
final matchesEmail = addr.email.toLowerCase().contains(ql);
final matchesName = addr.name?.toLowerCase().contains(ql) ?? false;
if (!matchesEmail && !matchesName) continue;
seen.add(addr.email);
final addrEmail = addr.email;
final count = addressEmails
.where(
(e) => [...e.from, ...e.to, ...e.cc]
.any((a) => a.email == addrEmail),
)
.length;
addresses.add((addr, count));
}
}
if (mounted) {
setState(() {
_results = _SearchResults(
mailboxes: matchedMailboxes,
addresses: addresses,
emails: emails,
);
_loading = false;
});
}
} catch (e) {
log('Search failed: $e');
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _ctrl,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search folders, addresses, emails…',
border: InputBorder.none,
),
onChanged: _onChanged,
),
actions: [
if (_ctrl.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_ctrl.clear();
setState(() => _results = null);
},
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_loading) return const Center(child: CircularProgressIndicator());
if (_results == null) {
return const Center(child: Text('Type 3+ characters to search'));
}
final r = _results!;
if (r.isEmpty) return const Center(child: Text('No results'));
return ListView(
children: [
if (r.mailboxes.isNotEmpty) ...[
const _SectionHeader('Folders'),
for (final mb in r.mailboxes)
_FolderTile(mb: mb, accountId: widget.accountId),
],
if (r.addresses.isNotEmpty) ...[
const _SectionHeader('Addresses'),
for (final (addr, count) in r.addresses)
_AddressTile(
addr: addr,
count: count,
accountId: widget.accountId,
),
],
if (r.emails.isNotEmpty) ...[
const _SectionHeader('Messages'),
for (final e in r.emails)
_EmailTile(email: e, accountId: widget.accountId),
],
],
);
}
}
class _SearchResults {
const _SearchResults({
required this.mailboxes,
required this.addresses,
required this.emails,
});
final List<Mailbox> mailboxes;
final List<(EmailAddress, int)> addresses;
final List<Email> emails;
bool get isEmpty => mailboxes.isEmpty && addresses.isEmpty && emails.isEmpty;
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(title, style: Theme.of(context).textTheme.labelLarge),
);
}
}
class _FolderTile extends StatelessWidget {
const _FolderTile({required this.mb, required this.accountId});
final Mailbox mb;
final String accountId;
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(Icons.folder),
title: Text(
mb.name,
style: mb.unreadCount > 0
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
trailing:
mb.unreadCount > 0 ? Badge(label: Text('${mb.unreadCount}')) : null,
onTap: () => context.go(
'/accounts/$accountId/mailboxes'
'/${Uri.encodeComponent(mb.path)}/emails',
),
);
}
}
class _AddressTile extends StatelessWidget {
const _AddressTile({
required this.addr,
required this.count,
required this.accountId,
});
final EmailAddress addr;
final int count;
final String accountId;
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(Icons.person),
title: Text(addr.name ?? addr.email),
subtitle: addr.name != null ? Text(addr.email) : null,
trailing: Text('$count mail${count == 1 ? '' : 's'}'),
onTap: () => context.push(
'/accounts/$accountId/emails/by-address'
'/${Uri.encodeComponent(addr.email)}',
),
);
}
}
class _EmailTile extends StatelessWidget {
const _EmailTile({required this.email, required this.accountId});
final Email email;
final String accountId;
@override
Widget build(BuildContext context) {
final sender = email.from.isNotEmpty
? (email.from.first.name ?? email.from.first.email)
: '(unknown)';
return ListTile(
leading: Icon(
email.isSeen ? Icons.mail_outline : Icons.mail,
color: email.isSeen ? null : Theme.of(context).colorScheme.primary,
),
title: Text(sender),
subtitle: Text(
email.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
email.mailboxPath,
style: Theme.of(context).textTheme.bodySmall,
),
onTap: () => context.push(
'/accounts/$accountId/mailboxes'
'/${Uri.encodeComponent(email.mailboxPath)}'
'/emails/${Uri.encodeComponent(email.id)}',
),
);
}
}