Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 0e3816a22f refactor(A1): extract EmailDetailNotifier, drop initState DB coupling
EmailDetailScreen no longer calls the repository directly in initState.
A new EmailDetailNotifier (AsyncNotifierProvider.autoDispose.family) owns
the load + setFlag(seen) side effect; the screen watches the provider and
renders via detail.when(loading/error/data).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 09:17:14 +02:00
2 changed files with 133 additions and 125 deletions
+23 -1
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
@@ -17,7 +18,7 @@ import 'package:sharedinbox/core/services/undo_service.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
import 'package:sharedinbox/core/sync/reliability_runner.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
@@ -168,6 +169,27 @@ final undoServiceProvider =
return service;
});
/// Loads email header + body and marks the email as seen.
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
final emailDetailProvider = AsyncNotifierProvider.autoDispose
.family<EmailDetailNotifier, (Email?, EmailBody), String>(
EmailDetailNotifier.new,
);
class EmailDetailNotifier
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
@override
Future<(Email?, EmailBody)> build(String emailId) async {
final repo = ref.read(emailRepositoryProvider);
final results = await Future.wait([
repo.getEmail(emailId),
repo.getEmailBody(emailId),
]);
unawaited(repo.setFlag(emailId, seen: true));
return (results[0] as Email?, results[1] as EmailBody);
}
}
final accountByIdProvider =
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
return ref.watch(accountRepositoryProvider).observeAccounts().map(
+21 -35
View File
@@ -26,36 +26,27 @@ class EmailDetailScreen extends ConsumerStatefulWidget {
}
class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
late final Future<(Email?, EmailBody)> _dataFuture;
bool _isFlagged = false;
bool _loadRemoteImages = false;
final Set<String> _downloading = {};
@override
void initState() {
super.initState();
final repo = ref.read(emailRepositoryProvider);
_dataFuture = Future.wait([
repo.getEmail(widget.emailId),
repo.getEmailBody(widget.emailId),
]).then((results) {
final email = results[0] as Email?;
Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider);
final detail = ref.watch(emailDetailProvider(widget.emailId));
ref.listen<AsyncValue<(Email?, EmailBody)>>(
emailDetailProvider(widget.emailId),
(_, next) {
final email = next.valueOrNull?.$1;
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
return (email, results[1] as EmailBody);
});
unawaited(repo.setFlag(widget.emailId, seen: true));
}
},
);
@override
Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider);
return FutureBuilder<(Email?, EmailBody)>(
future: _dataFuture,
builder: (ctx, snap) {
final header = snap.data?.$1;
final body = snap.data?.$2;
final header = detail.valueOrNull?.$1;
final body = detail.valueOrNull?.$2;
return Scaffold(
appBar: AppBar(
@@ -81,9 +72,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
IconButton(
icon: const Icon(Icons.forward),
tooltip: 'Forward',
onPressed: header == null
? null
: () => _forward(context, header, body),
onPressed:
header == null ? null : () => _forward(context, header, body),
),
IconButton(
icon: const Icon(Icons.mark_email_unread_outlined),
@@ -108,14 +98,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
tooltip: 'Move to folder',
onPressed:
header == null ? null : () => _moveTo(context, header),
onPressed: header == null ? null : () => _moveTo(context, header),
),
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed:
header == null ? null : () => _snooze(context, header),
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton(
icon: const Icon(Icons.delete),
@@ -157,13 +145,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
),
],
),
body: snap.connectionState == ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: snap.hasError
? Center(child: Text('Error: ${snap.error}'))
: _buildBody(ctx, header, body!),
);
},
body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (d) => _buildBody(context, d.$1, d.$2),
),
);
}