Files
sharedinbox/lib/ui/screens/email_list_screen.dart
T
Claude CodeandClaude Opus 4.7 ee14b88bc4 refactor(ui): unify email-list code across folder, combined inbox, search
Closes #533.

Pull selection, swipe, pagination and batch actions out of three
near-duplicate screens (EmailListScreen, CombinedInboxScreen,
AddressEmailsScreen) into a single shared widget. Folder view, combined
inbox, in-folder search results and by-address lists now share one tile
renderer, one selection controller and one batch-action bottom bar.

- New EmailThreadList widget + EmailThreadListController own the
  list rendering, selection set, optional swipe-to-archive/delete and
  optional pagination. Hosts listen to the controller to swap between
  their normal AppBar/drawer/FAB and the shared selection AppBar /
  BottomAppBar (buildSelectionAppBar, buildSelectionBottomBar).
- Batch actions (batchArchive, batchDelete, batchMarkSpam,
  batchMove, batchSnooze) and swipeDismissThread move to
  email_action_helpers.dart and group threads by account so multi-
  account selections produce correctly scoped repository calls and undo
  actions. The combined inbox now supports the full action set (was
  archive + delete only).
- The duplicate EmailThreadTile widget is removed; ThreadTile is the
  single tile used everywhere. Search results now render with the same
  unread/flag icons as the inbox list.
- AddressEmailsScreen adopts the shared list, gaining selection +
  batch actions for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 12:59:08 +00:00

417 lines
13 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/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_thread_list.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
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> {
final _searchController = SearchController();
List<Email>? _searchResults;
bool _searchLoading = false;
bool get _searching => _searchController.text.isNotEmpty;
// Error banner — tracks the last error message that the user dismissed.
String? _dismissedError;
late final EmailThreadListController _selection;
// Pagination: number of threads currently requested from the DB.
static const _pageSize = 50;
int _limit = _pageSize;
// Incremented on every search start; stale completions are ignored when the
// generation has advanced (prevents out-of-order IMAP responses from
// overwriting fresh results with results for an older query).
int _searchGeneration = 0;
// The query whose results are currently settled in _searchResults.
// Used to skip redundant re-runs when the user presses Enter on an
// already-settled search (issue #473).
String? _lastSettledQuery;
@override
void initState() {
super.initState();
_selection = EmailThreadListController()..addListener(_onSelectionChange);
_searchController.addListener(() {
if (_searchController.text.isEmpty) {
setState(() {
_searchResults = null;
_searchLoading = false;
_lastSettledQuery = null;
});
}
});
}
@override
void dispose() {
_selection
..removeListener(_onSelectionChange)
..dispose();
_searchController.dispose();
super.dispose();
}
void _onSelectionChange() {
if (mounted) setState(() {});
}
Future<void> _runSearch(String query) async {
final q = query.trim();
if (q.isEmpty) {
setState(() {
_searchResults = null;
_lastSettledQuery = null;
});
return;
}
// Skip if results are already settled for this exact query — prevents the
// Enter key from re-triggering a search that already completed.
if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) {
return;
}
final generation = ++_searchGeneration;
setState(() => _searchLoading = true);
try {
final results = await ref
.read(emailRepositoryProvider)
.searchEmails(widget.accountId, widget.mailboxPath, q);
if (mounted && generation == _searchGeneration) {
setState(() {
_searchResults = results;
_lastSettledQuery = q;
});
}
} finally {
if (mounted && generation == _searchGeneration) {
setState(() => _searchLoading = false);
}
}
}
void _onSearchChanged(String value) {
if (value.trim().isNotEmpty) unawaited(_runSearch(value.trim()));
}
@override
Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
final selecting = _selection.isSelecting;
return Scaffold(
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
drawer: selecting
? null
: FolderDrawer(
accountId: widget.accountId,
currentMailboxPath: widget.mailboxPath,
),
bottomNavigationBar: selecting
? buildSelectionBottomBar(
context,
ref,
_selection,
onAfterAction: _onAfterBatchAction,
)
: (menuAtBottom ? _folderNavBottomBar() : null),
body: Column(
children: [
_buildSyncErrorBanner(),
Expanded(
child: (_searchResults != null || _searchLoading)
? _buildSearchBody()
: _buildStreamBody(repo),
),
],
),
);
}
PreferredSizeWidget _buildAppBar(
EmailRepository emailRepo,
AsyncValue<Account?> accountAsync, {
required bool menuAtBottom,
}) {
if (_selection.isSelecting) {
return buildSelectionAppBar(_selection);
}
return AppBar(
automaticallyImplyLeading: !menuAtBottom,
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,
),
),
),
),
_buildSyncButton(emailRepo),
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => context.push(
'/compose',
extra: {'accountId': widget.accountId},
),
),
PopupMenuButton<String>(
onSelected: (value) async {
if (value == 'mark_all_read') {
await emailRepo.markAllAsRead(
widget.accountId,
widget.mailboxPath,
);
}
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'mark_all_read',
child: Text('Mark all as read'),
),
],
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: SearchBar(
controller: _searchController,
hintText: 'Search…',
leading: const Icon(Icons.search),
trailing: [
if (_searchController.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
),
],
onChanged: _onSearchChanged,
onSubmitted: (value) {
// Only run the search if results haven't settled yet via
// onChanged — prevents a second IMAP round-trip from reordering
// the already-visible results when the user presses Enter.
if (_searchResults == null && !_searchLoading) {
unawaited(_runSearch(value));
}
},
textInputAction: TextInputAction.search,
),
),
),
);
}
Widget _buildSyncButton(EmailRepository emailRepo) {
final isSyncing =
ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
final hasError =
ref.watch(syncLastErrorProvider(widget.accountId)).value != null;
return IconButton(
tooltip: isSyncing
? 'Syncing…'
: hasError
? 'Sync error'
: 'Sync',
icon: isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: hasError
? const Icon(Icons.sync_problem, color: Colors.red)
: const Icon(Icons.sync),
onPressed: isSyncing
? null
: () async {
try {
await emailRepo.syncEmails(
widget.accountId,
widget.mailboxPath,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text('Sync failed: $e'),
),
);
}
},
);
}
Widget _folderNavBottomBar() {
return BottomAppBar(
child: Row(
children: [
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
],
),
);
}
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'));
}
final threads = _searchResults!.map(EmailThread.fromEmail).toList();
return EmailThreadList(
controller: _selection,
items: threads,
enableSwipe: false,
onTap: (t) => unawaited(_openSearchResultAndRefresh(t.latestEmailId)),
);
}
Widget _buildSyncErrorBanner() {
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
final error = errorAsync.value;
if (error == null || error == _dismissedError) {
return const SizedBox.shrink();
}
return MaterialBanner(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis),
leading: Icon(
Icons.sync_problem,
color: Theme.of(context).colorScheme.error,
),
backgroundColor: Theme.of(context).colorScheme.errorContainer,
actions: [
TextButton(
onPressed: () {
ref.read(syncManagerProvider).syncNow(widget.accountId);
},
child: const Text('Retry'),
),
TextButton(
onPressed: () =>
context.push('/accounts/${widget.accountId}/sync-log'),
child: const Text('View log'),
),
TextButton(
onPressed: () => setState(() => _dismissedError = error),
child: const Text('Dismiss'),
),
],
);
}
Widget _buildStreamBody(EmailRepository emailRepo) {
return RefreshIndicator(
onRefresh: () async {
// Trigger a background sync cycle immediately.
ref.read(syncManagerProvider).syncNow(widget.accountId);
// Also wait for this specific mailbox to sync for immediate feedback.
await emailRepo.syncEmails(widget.accountId, widget.mailboxPath);
},
child: EmailThreadList(
controller: _selection,
stream: emailRepo.observeThreads(
widget.accountId,
widget.mailboxPath,
limit: _limit,
),
enablePagination: true,
onLoadMore: () => setState(() => _limit += _pageSize),
),
);
}
Future<void> _openSearchResultAndRefresh(String emailId) async {
await context.push(
'/accounts/${widget.accountId}/mailboxes'
'/${Uri.encodeComponent(widget.mailboxPath)}'
'/emails/${Uri.encodeComponent(emailId)}',
);
await _refreshSearchAndPopIfEmpty();
}
Future<void> _refreshSearchAndPopIfEmpty() async {
if (!mounted || !_searching) return;
final query = _searchController.text.trim();
final remaining = await ref
.read(emailRepositoryProvider)
.searchEmails(widget.accountId, widget.mailboxPath, query);
if (!mounted) return;
if (remaining.isEmpty) {
if (context.canPop()) {
context.pop();
return;
}
_searchController.clear();
return;
}
setState(() => _searchResults = remaining);
}
void _onAfterBatchAction(List<String> actedThreadIds) {
if (!_searching || !mounted) return;
// Filter acted-on emails out of the local results immediately. Calling
// searchEmails would still return them because the delete is only
// enqueued — not yet applied to the local DB.
final actedSet = actedThreadIds.toSet();
final remaining = (_searchResults ?? [])
.where((e) => !actedSet.contains(e.threadId ?? e.id))
.toList();
if (remaining.isEmpty) {
if (context.canPop()) {
context.pop();
return;
}
_searchController.clear();
return;
}
setState(() => _searchResults = remaining);
}
}