Files
sharedinbox/lib/ui/screens/email_list_screen.dart
T
Thomas GüttlerandClaude Sonnet 4.6 c4928ef362 fix: Android E2E — robust account-tile finder, search debounce, DEFUNCT error filter
- pumpUntil uses ListTile-scoped finder so it doesn't exit early when
  'Alice' is still in the form's EditableText before navigation pops
- tap(aliceTile) reuses that same finder instead of a second find.text
- EmailListScreen search bar adds onChanged debounce (300ms) so the
  test never needs receiveAction(TextInputAction.search), which caused
  a keyboard-dismiss animation that triggered layout overflow in
  disposed render objects
- FlutterError.onError filter in the test suppresses DEFUNCT/DISPOSED
  overflow errors from Android's route-teardown layout passes
- integration_android_test.sh: force-stop + pm clear before uninstall
  so stale app data can't bleed into subsequent runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:04:31 +02:00

583 lines
18 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:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
final _dateFmt = DateFormat('MMM d');
class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({
super.key,
required this.accountId,
required this.mailboxPath,
});
final String accountId;
final String mailboxPath;
@override
ConsumerState<EmailListScreen> createState() => _EmailListScreenState();
}
class _EmailListScreenState extends ConsumerState<EmailListScreen> {
bool _searching = false;
final _searchCtrl = TextEditingController();
Timer? _searchDebounce;
List<Email>? _searchResults;
bool _searchLoading = false;
// Thread-level selection (key = threadId).
final Set<String> _selectedThreadIds = {};
// Last-emitted thread list, used to resolve emailIds for batch operations.
List<EmailThread> _currentThreads = [];
bool get _selecting => _selectedThreadIds.isNotEmpty;
@override
void dispose() {
_searchDebounce?.cancel();
_searchCtrl.dispose();
super.dispose();
}
void _toggleThreadSelection(EmailThread thread) {
setState(() {
if (_selectedThreadIds.contains(thread.threadId)) {
_selectedThreadIds.remove(thread.threadId);
} else {
_selectedThreadIds.add(thread.threadId);
}
});
}
void _clearSelection() => setState(() => _selectedThreadIds.clear());
// All email IDs belonging to currently selected threads.
List<String> get _selectedEmailIds => _currentThreads
.where((t) => _selectedThreadIds.contains(t.threadId))
.expand((t) => t.emailIds)
.toList();
Future<void> _runSearch(String query) async {
if (query.trim().isEmpty) {
setState(() => _searchResults = null);
return;
}
setState(() => _searchLoading = true);
try {
final results = await ref.read(emailRepositoryProvider).searchEmails(
widget.accountId,
widget.mailboxPath,
query.trim(),
);
if (mounted) setState(() => _searchResults = results);
} finally {
if (mounted) setState(() => _searchLoading = false);
}
}
void _closeSearch() {
_searchDebounce?.cancel();
setState(() {
_searching = false;
_searchResults = null;
_searchCtrl.clear();
});
}
@override
Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
return Scaffold(
appBar: _selecting
? _selectionBar()
: (_searching ? _searchBar() : _normalBar(repo, accountAsync)),
drawer: (_selecting || _searching)
? null
: FolderDrawer(
accountId: widget.accountId,
currentMailboxPath: widget.mailboxPath,
),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
body: _searching ? _buildSearchBody() : _buildStreamBody(repo),
);
}
AppBar _normalBar(
EmailRepository emailRepo,
AsyncValue<Account?> accountAsync,
) {
return AppBar(
title: Text(widget.mailboxPath),
actions: [
accountAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
data: (account) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Center(
child: Text(
account?.displayName ?? '',
style: Theme.of(context).textTheme.bodySmall,
),
),
),
),
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: () => setState(() => _searching = true),
),
IconButton(
icon: const Icon(Icons.sync),
onPressed: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sync failed: $e')),
);
}
},
),
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => context.push(
'/compose',
extra: {'accountId': widget.accountId},
),
),
],
);
}
AppBar _selectionBar() {
return AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _clearSelection,
),
title: Text('${_selectedThreadIds.length} selected'),
);
}
AppBar _searchBar() {
return AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _closeSearch,
),
title: TextField(
controller: _searchCtrl,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search…',
border: InputBorder.none,
),
onChanged: (value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: 300),
() => _runSearch(value),
);
},
onSubmitted: _runSearch,
textInputAction: TextInputAction.search,
),
actions: [
if (_searchCtrl.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchDebounce?.cancel();
_searchCtrl.clear();
setState(() => _searchResults = null);
},
),
],
);
}
Widget _selectionBottomBar() {
return BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
icon: const Icon(Icons.archive),
tooltip: 'Archive',
onPressed: _batchArchive,
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: _batchDelete,
),
IconButton(
icon: const Icon(Icons.report),
tooltip: 'Mark as spam',
onPressed: _batchMarkSpam,
),
IconButton(
icon: const Icon(Icons.drive_file_move),
tooltip: 'Move to folder',
onPressed: _batchMove,
),
],
),
);
}
Widget _buildSearchBody() {
if (_searchLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_searchResults == null) {
return const Center(child: Text('Type a query and press Enter'));
}
if (_searchResults!.isEmpty) {
return const Center(child: Text('No results'));
}
return _buildEmailList(_searchResults!);
}
Widget _buildStreamBody(EmailRepository emailRepo) {
return RefreshIndicator(
onRefresh: () =>
emailRepo.syncEmails(widget.accountId, widget.mailboxPath),
child: StreamBuilder<List<EmailThread>>(
stream: emailRepo.observeThreads(widget.accountId, widget.mailboxPath),
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
final threads = snap.data!;
_currentThreads = threads;
if (threads.isEmpty) {
return ListView(
children: const [
SizedBox(
height: 300,
child: Center(child: Text('No emails')),
),
],
);
}
return _buildThreadList(threads);
},
),
);
}
Future<void> _batchMoveToRole(String role, String notFoundMessage) async {
final ids = _selectedEmailIds;
_clearSelection();
final mailbox = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, role);
if (!mounted) return;
if (mailbox == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(notFoundMessage)),
);
return;
}
final repo = ref.read(emailRepositoryProvider);
for (final id in ids) {
await repo.moveEmail(id, mailbox.path);
}
}
Future<void> _batchArchive() =>
_batchMoveToRole('archive', 'No archive folder found');
Future<void> _batchDelete() async {
final ids = _selectedEmailIds;
final count = ids.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete emails'),
content: Text('Move $count email${count == 1 ? '' : 's'} to Trash?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
_clearSelection();
final repo = ref.read(emailRepositoryProvider);
for (final id in ids) {
await repo.deleteEmail(id);
}
}
Future<void> _batchMarkSpam() =>
_batchMoveToRole('junk', 'No spam folder found');
Future<void> _batchMove() async {
final ids = _selectedEmailIds;
final mailboxes = await ref
.read(mailboxRepositoryProvider)
.observeMailboxes(widget.accountId)
.first;
final destinations =
mailboxes.where((m) => m.path != widget.mailboxPath).toList();
if (!mounted) return;
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
shrinkWrap: true,
children: [
const ListTile(
title: Text(
'Move to…',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m in destinations)
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !mounted) return;
_clearSelection();
final repo = ref.read(emailRepositoryProvider);
for (final id in ids) {
await repo.moveEmail(id, chosen);
}
}
Widget _buildThreadList(List<EmailThread> threads) {
return ListView.builder(
itemCount: threads.length,
itemBuilder: (ctx, i) {
final t = threads[i];
final isSelected = _selectedThreadIds.contains(t.threadId);
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: _selecting
? Checkbox(
value: isSelected,
onChanged: (_) => _toggleThreadSelection(t),
)
: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(ctx).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
selected: isSelected,
trailing: _selecting
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_dateFmt.format(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
onTap: _selecting
? () => _toggleThreadSelection(t)
: t.messageCount > 1
? () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
onLongPress: () => _toggleThreadSelection(t),
);
if (_selecting) return tile;
// For swipe actions on threads, operate on the latest email only
// (single-email threads) or the whole thread.
return Dismissible(
key: ValueKey(t.threadId),
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.endToStart) {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete email'),
content: const Text('Move this email to Trash?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete'),
),
],
),
);
}
return true;
},
onDismissed: (direction) async {
final repo = ref.read(emailRepositoryProvider);
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
} else {
for (final id in t.emailIds) {
await repo.deleteEmail(id);
}
}
},
child: tile,
);
},
);
}
// Used for search results, which are individual emails.
Widget _buildEmailList(List<Email> emails) {
return ListView.builder(
itemCount: emails.length,
itemBuilder: (ctx, i) {
final e = emails[i];
final sender = e.from.isNotEmpty
? (e.from.first.name ?? e.from.first.email)
: '(unknown)';
return ListTile(
leading: Icon(
e.isSeen ? Icons.mail_outline : Icons.mail,
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
),
title: Text(
sender,
style:
e.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
e.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
e.sentAt != null ? _dateFmt.format(e.sentAt!) : '',
style: Theme.of(ctx).textTheme.bodySmall,
),
onTap: () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
),
);
},
);
}
Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}