Files
sharedinbox/lib/ui/widgets/email_thread_list.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

433 lines
13 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart' show listEquals;
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/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
/// Controller for [EmailThreadList].
///
/// Holds the current selection set and the last-seen thread list, so the host
/// screen can listen for selection-mode changes (to swap AppBars, hide the
/// drawer, etc.) and read [selectedThreads] when wiring batch-action buttons.
class EmailThreadListController extends ChangeNotifier {
final Set<String> _selected = <String>{};
List<EmailThread> _threads = const [];
/// All threads currently rendered (latest stream emission or static input).
List<EmailThread> get visibleThreads => List.unmodifiable(_threads);
/// Threads whose `threadId` is selected (preserving the list's order).
List<EmailThread> get selectedThreads =>
_threads.where((t) => _selected.contains(t.threadId)).toList();
Set<String> get selectedIds => Set.unmodifiable(_selected);
bool get isSelecting => _selected.isNotEmpty;
int get selectionCount => _selected.length;
bool isSelected(EmailThread t) => _selected.contains(t.threadId);
void toggle(EmailThread t) {
if (!_selected.add(t.threadId)) {
_selected.remove(t.threadId);
}
notifyListeners();
}
void clear() {
if (_selected.isEmpty) return;
_selected.clear();
notifyListeners();
}
void selectAll() {
final before = _selected.length;
_selected.addAll(_threads.map((t) => t.threadId));
if (_selected.length != before) notifyListeners();
}
/// Called by [EmailThreadList] whenever the visible threads change. Drops
/// any selected ids that no longer appear in the list. Hosts should not
/// call this directly.
void updateThreads(List<EmailThread> threads) {
_threads = threads;
final visibleIds = threads.map((t) => t.threadId).toSet();
final before = _selected.length;
_selected.retainAll(visibleIds);
if (_selected.length != before) notifyListeners();
}
}
/// A unified list of email threads used by folder, combined-inbox, search and
/// address-emails views.
///
/// Renders selection-mode checkboxes, optional swipe-to-archive/delete and
/// optional pagination. Selection state lives in [controller]; the host screen
/// listens to it to swap its AppBar / BottomBar for selection-mode equivalents
/// (see [buildSelectionAppBar] / [buildSelectionBottomBar]).
///
/// Provide exactly one of [stream] (live data) or [items] (static list, used
/// for search / by-address results).
class EmailThreadList extends ConsumerStatefulWidget {
const EmailThreadList({
super.key,
required this.controller,
this.stream,
this.items,
this.enableSwipe = true,
this.enablePagination = false,
this.pageSize = 50,
this.showAccountLabel = false,
this.showLocationLabel = false,
this.accountNames = const {},
this.onTap,
this.onLoadMore,
this.emptyMessage = 'No emails',
}) : assert(
(stream == null) != (items == null),
'Provide exactly one of stream or items',
);
final EmailThreadListController controller;
/// Live thread source (folder view, combined inbox). Mutually exclusive with
/// [items].
final Stream<List<EmailThread>>? stream;
/// Static thread list (search results, by-address). Mutually exclusive with
/// [stream].
final List<EmailThread>? items;
/// When true, threads can be swiped to archive (start→end) or delete
/// (end→start). Disabled for search-result lists where a swipe would
/// silently drop an item from a filtered view.
final bool enableSwipe;
/// When true, the list shows a "Load more" button once the visible count
/// equals the current page size.
final bool enablePagination;
/// Page size for [enablePagination].
final int pageSize;
/// Show an extra subtitle line with the account name (combined inbox).
/// Looked up in [accountNames] keyed by `accountId`.
final bool showAccountLabel;
final Map<String, String> accountNames;
/// Show a per-tile location label ("accountId • mailboxPath"). Used by
/// global search results.
final bool showLocationLabel;
/// Optional tap handler. When null, the default navigates to the email or
/// thread detail route based on `messageCount`.
final ValueChanged<EmailThread>? onTap;
/// Notification fired when the user taps "Load more". Hosts that use a
/// stream can grow their `limit` here.
final VoidCallback? onLoadMore;
/// Message shown when the list is empty.
final String emptyMessage;
@override
ConsumerState<EmailThreadList> createState() => _EmailThreadListState();
}
class _EmailThreadListState extends ConsumerState<EmailThreadList> {
int _limit = 50;
@override
void initState() {
super.initState();
_limit = widget.pageSize;
widget.controller.addListener(_onControllerChange);
}
@override
void didUpdateWidget(EmailThreadList oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.controller, widget.controller)) {
oldWidget.controller.removeListener(_onControllerChange);
widget.controller.addListener(_onControllerChange);
}
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChange);
super.dispose();
}
void _onControllerChange() {
if (mounted) setState(() {});
}
void _publishThreads(List<EmailThread> threads) {
if (listEquals(threads, widget.controller.visibleThreads)) return;
// Defer so we don't notifyListeners during a build phase.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.controller.updateThreads(threads);
});
}
@override
Widget build(BuildContext context) {
if (widget.items != null) {
return _buildList(widget.items!);
}
return StreamBuilder<List<EmailThread>>(
stream: widget.stream,
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
return _buildList(snap.data!);
},
);
}
Widget _buildList(List<EmailThread> threads) {
_publishThreads(threads);
if (threads.isEmpty) {
return ListView(
children: [
SizedBox(
height: 300,
child: Center(child: Text(widget.emptyMessage)),
),
],
);
}
final hasMore = widget.enablePagination && threads.length == _limit;
return ListView.builder(
itemCount: threads.length + (hasMore ? 1 : 0),
itemBuilder: (ctx, i) {
if (i == threads.length) {
return TextButton(
onPressed: () {
setState(() => _limit += widget.pageSize);
widget.onLoadMore?.call();
},
child: const Text('Load more'),
);
}
return _tileFor(threads[i]);
},
);
}
Widget _tileFor(EmailThread t) {
final isSelected = widget.controller.isSelected(t);
final isSelecting = widget.controller.isSelecting;
final accountName = widget.accountNames[t.accountId];
final locationLabel = widget.showLocationLabel
? '${t.accountId}${t.mailboxPath}'
: widget.showAccountLabel
? accountName
: null;
final tile = ThreadTile(
thread: t,
selected: isSelected,
locationLabel: locationLabel,
leading: isSelecting
? SizedBox(
width: 40,
child: Checkbox(
value: isSelected,
onChanged: (_) => widget.controller.toggle(t),
),
)
: null,
onTap: () => _onTileTap(t),
onLongPress: () => widget.controller.toggle(t),
);
if (!widget.enableSwipe) return tile;
return Dismissible(
key: ValueKey('${t.accountId}:${t.threadId}'),
direction:
isSelecting ? DismissDirection.none : DismissDirection.horizontal,
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',
),
onDismissed: (direction) =>
unawaited(swipeDismissThread(ref, t, direction)),
child: tile,
);
}
void _onTileTap(EmailThread t) {
if (widget.controller.isSelecting) {
widget.controller.toggle(t);
return;
}
if (widget.onTap != null) {
widget.onTap!(t);
return;
}
if (t.messageCount > 1) {
unawaited(
context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
),
);
return;
}
unawaited(
context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
);
}
static 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)),
],
),
);
}
}
/// Standard "N selected" AppBar with X-close and select-all actions.
PreferredSizeWidget buildSelectionAppBar(EmailThreadListController controller) {
return AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: controller.clear,
),
title: Text('${controller.selectionCount} selected'),
actions: [
IconButton(
icon: const Icon(Icons.select_all),
tooltip: 'Select all',
onPressed: controller.selectAll,
),
],
);
}
/// Standard batch-action BottomAppBar.
///
/// [onAfterAction] runs after the helper finishes and the selection is
/// cleared. It receives the list of thread IDs that were targeted so the host
/// can refresh static result lists (e.g. search results) and pop if empty.
Widget buildSelectionBottomBar(
BuildContext context,
WidgetRef ref,
EmailThreadListController controller, {
bool includeArchive = true,
bool includeDelete = true,
bool includeSpam = true,
bool includeMove = true,
bool includeSnooze = true,
void Function(List<String> actedThreadIds)? onAfterAction,
}) {
void run(Future<void> Function() body) {
final actedIds = controller.selectedThreads.map((t) => t.threadId).toList();
unawaited(() async {
await body();
controller.clear();
onAfterAction?.call(actedIds);
}());
}
return BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (includeArchive)
IconButton(
icon: const Icon(Icons.archive),
tooltip: 'Archive',
onPressed: () => run(
() => batchArchive(
context,
ref,
threads: controller.selectedThreads,
),
),
),
if (includeDelete)
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: () => run(
() => batchDelete(ref, threads: controller.selectedThreads),
),
),
if (includeSpam)
IconButton(
icon: const Icon(Icons.report),
tooltip: 'Mark as spam',
onPressed: () => run(
() => batchMarkSpam(
context,
ref,
threads: controller.selectedThreads,
),
),
),
if (includeMove)
IconButton(
icon: const Icon(Icons.drive_file_move),
tooltip: 'Move to folder',
onPressed: () => run(
() => batchMove(
context,
ref,
threads: controller.selectedThreads,
),
),
),
if (includeSnooze)
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed: () => run(
() => batchSnooze(
context,
ref,
threads: controller.selectedThreads,
),
),
),
],
),
);
}