- 登录: QQ/163/126/189/搜狐/Gmail/Outlook/Yahoo/iCloud/企业邮/自定义, 自动识别+测试连接 - 收信: IMAP 多文件夹同步/未读/星标/搜索(本地+服务器) - 读信: HTML渲染/CID内嵌图/原文查看/附件下载 - 写信: 收件人/抄送/密送/附件/富文本/草稿/回复转发 - 存储: SQLite + 系统安全存储(DPAPI/Keychain/Keystore) - 测试: 单元测试 + Mock IMAP/SMTP 服务器集成测试(23项全过) - 可迁移: Windows/Android/iOS/macOS/Linux 一套代码
388 lines
12 KiB
Dart
388 lines
12 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../core/models/account.dart';
|
|
import '../core/models/address.dart';
|
|
import '../core/models/email.dart';
|
|
import '../core/service/email_service.dart';
|
|
import '../core/storage/stores.dart';
|
|
|
|
/// 全局应用状态(ChangeNotifier, 单例)
|
|
class AppState extends ChangeNotifier {
|
|
AppState._();
|
|
|
|
static final AppState instance = AppState._();
|
|
|
|
/// 深色模式开关(供 MaterialApp 监听)
|
|
final ValueNotifier<bool> darkMode = ValueNotifier(false);
|
|
|
|
final AccountStore _accountStore = AccountStore();
|
|
final FolderStore _folderStore = FolderStore();
|
|
final EmailStore _emailStore = EmailStore();
|
|
final EmailService _service = EmailService();
|
|
|
|
// ---- 数据 ----
|
|
List<EmailAccount> accounts = [];
|
|
Map<int, List<MailFolder>> foldersByAccount = {};
|
|
Map<int, Map<String, int>> unreadByFolder = {}; // accountId -> folder -> unread
|
|
|
|
// ---- 界面状态 ----
|
|
int? currentAccountId;
|
|
String? currentFolder; // 完整文件夹名
|
|
List<Email> currentEmails = [];
|
|
bool loadingEmails = false;
|
|
String? syncError;
|
|
|
|
EmailAccount? get currentAccount {
|
|
for (final a in accounts) {
|
|
if (a.id == currentAccountId) return a;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
List<MailFolder> get currentFolders =>
|
|
foldersByAccount[currentAccountId] ?? const [];
|
|
|
|
bool get isBusy => loadingEmails;
|
|
|
|
// ---- 初始化 ----
|
|
|
|
Future<void> init() async {
|
|
await loadAccounts();
|
|
}
|
|
|
|
Future<void> loadAccounts() async {
|
|
accounts = await _accountStore.getAll();
|
|
if (accounts.isNotEmpty) {
|
|
currentAccountId ??= accounts.first.id;
|
|
}
|
|
for (final a in accounts) {
|
|
final folders = await _folderStore.getByAccount(a.id!);
|
|
foldersByAccount[a.id!] = folders;
|
|
final unread = <String, int>{};
|
|
for (final f in folders) {
|
|
unread[f.name] = await _emailStore.countUnread(a.id!, f.name);
|
|
}
|
|
unreadByFolder[a.id!] = unread;
|
|
if (currentFolder == null && folders.isNotEmpty) {
|
|
currentFolder = folders.first.name;
|
|
}
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> addAccount(EmailAccount account, String password) async {
|
|
final saved = await _accountStore.insert(account);
|
|
await SecureStore.savePassword(account.email, password);
|
|
// 同步文件夹
|
|
try {
|
|
final folders = await _service.syncFolders(saved, password);
|
|
foldersByAccount[saved.id!] = folders;
|
|
unreadByFolder[saved.id!] = {
|
|
for (final f in folders) f.name: 0
|
|
};
|
|
} catch (_) {}
|
|
await loadAccounts();
|
|
currentAccountId ??= saved.id;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> removeAccount(EmailAccount account) async {
|
|
await _accountStore.delete(account.id!);
|
|
await SecureStore.deletePassword(account.email);
|
|
foldersByAccount.remove(account.id!);
|
|
unreadByFolder.remove(account.id!);
|
|
if (currentAccountId == account.id) {
|
|
currentAccountId = accounts.isNotEmpty ? accounts.first.id : null;
|
|
currentFolder = null;
|
|
currentEmails = [];
|
|
}
|
|
await loadAccounts();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> updateAccount(EmailAccount account, {String? newPassword}) async {
|
|
await _accountStore.update(account);
|
|
if (newPassword != null) {
|
|
await SecureStore.savePassword(account.email, newPassword);
|
|
}
|
|
await loadAccounts();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<String?> getPassword(EmailAccount account) =>
|
|
SecureStore.readPassword(account.email);
|
|
|
|
// ---- 文件夹与邮件 ----
|
|
|
|
void selectAccount(int accountId) {
|
|
currentAccountId = accountId;
|
|
final folders = foldersByAccount[accountId] ?? [];
|
|
if (folders.isNotEmpty) {
|
|
final inbox = folders.where((f) => f.kind == 'inbox').toList();
|
|
currentFolder = inbox.isNotEmpty ? inbox.first.name : folders.first.name;
|
|
} else {
|
|
currentFolder = null;
|
|
}
|
|
currentEmails = [];
|
|
notifyListeners();
|
|
loadEmails();
|
|
}
|
|
|
|
void selectFolder(String folder) {
|
|
currentFolder = folder;
|
|
currentEmails = [];
|
|
notifyListeners();
|
|
loadEmails();
|
|
}
|
|
|
|
/// 加载当前文件夹邮件(本地)
|
|
Future<void> loadEmails() async {
|
|
final account = currentAccount;
|
|
final folder = currentFolder;
|
|
if (account == null || folder == null) return;
|
|
loadingEmails = true;
|
|
notifyListeners();
|
|
currentEmails = await _emailStore.queryByFolder(account.id!, folder);
|
|
loadingEmails = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 刷新:同步当前文件夹 + 更新未读数
|
|
Future<void> refresh({bool full = false}) async {
|
|
final account = currentAccount;
|
|
if (account == null) return;
|
|
syncError = null;
|
|
notifyListeners();
|
|
try {
|
|
final password = await SecureStore.readPassword(account.email);
|
|
if (password == null) {
|
|
syncError = '未找到密码,请在账户设置中重新输入';
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
final result = await _service.syncAll(account, password, fullResync: full);
|
|
// 刷新文件夹缓存
|
|
foldersByAccount[account.id!] = await _folderStore.getByAccount(account.id!);
|
|
// 更新未读
|
|
final unread = <String, int>{};
|
|
for (final f in foldersByAccount[account.id!] ?? []) {
|
|
unread[f.name] = await _emailStore.countUnread(account.id!, f.name);
|
|
}
|
|
unreadByFolder[account.id!] = unread;
|
|
if (result.hasError) {
|
|
syncError = result.errors.first;
|
|
}
|
|
} catch (e) {
|
|
syncError = '$e';
|
|
}
|
|
await loadEmails();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 重新拉取当前文件夹
|
|
Future<void> resyncCurrentFolder() async {
|
|
final account = currentAccount;
|
|
final folder = currentFolder;
|
|
if (account == null || folder == null) return;
|
|
final password = await SecureStore.readPassword(account.email);
|
|
if (password == null) return;
|
|
await _service.syncFolder(account, password, folder, fullResync: true);
|
|
await loadEmails();
|
|
}
|
|
|
|
// ---- 邮件操作 ----
|
|
|
|
Future<void> markSeen(List<Email> emails, {bool seen = true}) async {
|
|
final account = currentAccount;
|
|
if (account == null) return;
|
|
final password = await SecureStore.readPassword(account.email);
|
|
if (password == null) return;
|
|
try {
|
|
await _service.markSeen(account, password, emails, seen: seen);
|
|
} catch (e) {
|
|
syncError = '$e';
|
|
}
|
|
await loadEmails();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> toggleFlag(Email email) async {
|
|
final account = currentAccount;
|
|
if (account == null) return;
|
|
final password = await SecureStore.readPassword(account.email);
|
|
if (password == null) return;
|
|
try {
|
|
await _service.toggleFlag(account, password, email);
|
|
} catch (e) {
|
|
syncError = '$e';
|
|
}
|
|
await loadEmails();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> deleteEmails(List<Email> emails) async {
|
|
final account = currentAccount;
|
|
if (account == null) return;
|
|
final password = await SecureStore.readPassword(account.email);
|
|
if (password == null) return;
|
|
try {
|
|
await _service.deleteEmails(account, password, emails);
|
|
} catch (e) {
|
|
syncError = '$e';
|
|
}
|
|
await loadEmails();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> moveEmails(List<Email> emails, String destFolder) async {
|
|
final account = currentAccount;
|
|
if (account == null) return;
|
|
final password = await SecureStore.readPassword(account.email);
|
|
if (password == null) return;
|
|
try {
|
|
await _service.moveEmails(account, password, emails, destFolder);
|
|
} catch (e) {
|
|
syncError = '$e';
|
|
}
|
|
await loadEmails();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<Email> openEmail(Email meta, {bool forceRefresh = false}) async {
|
|
final account = currentAccount;
|
|
if (account == null) return meta;
|
|
final password = await SecureStore.readPassword(account.email);
|
|
if (password == null) return meta;
|
|
final full = await _service.getFullEmail(account, password, meta,
|
|
forceRefresh: forceRefresh);
|
|
if (!meta.seen) {
|
|
// 更新未读计数
|
|
final unread = unreadByFolder[account.id!] ?? {};
|
|
unread[meta.folder] = (unread[meta.folder] ?? 0) - 1 < 0 ? 0 : unread[meta.folder]! - 1;
|
|
unreadByFolder[account.id!] = unread;
|
|
}
|
|
await loadEmails();
|
|
notifyListeners();
|
|
return full;
|
|
}
|
|
|
|
Future<MailAttachment> downloadAttachment(
|
|
Email meta, MailAttachment attachment) async {
|
|
final account = currentAccount;
|
|
if (account == null) return attachment;
|
|
final password = await SecureStore.readPassword(account.email);
|
|
if (password == null) return attachment;
|
|
return _service.downloadAttachment(account, password, meta, attachment);
|
|
}
|
|
|
|
Future<void> sendEmail({
|
|
required List<MailAddress> to,
|
|
List<MailAddress> cc = const [],
|
|
List<MailAddress> bcc = const [],
|
|
required String subject,
|
|
String? textBody,
|
|
String? htmlBody,
|
|
List<({String name, List<int> bytes, String contentType})> attachments = const [],
|
|
String? inReplyTo,
|
|
String? references,
|
|
}) async {
|
|
final account = currentAccount;
|
|
if (account == null) throw Exception('请先添加账户');
|
|
final password = await SecureStore.readPassword(account.email);
|
|
if (password == null) throw Exception('未找到密码,请在账户设置中重新输入');
|
|
await _service.sendEmail(
|
|
account,
|
|
password,
|
|
to: to,
|
|
cc: cc,
|
|
bcc: bcc,
|
|
subject: subject,
|
|
textBody: textBody,
|
|
htmlBody: htmlBody,
|
|
attachments: attachments,
|
|
inReplyTo: inReplyTo,
|
|
references: references,
|
|
);
|
|
// 刷新已发送
|
|
final folders = foldersByAccount[account.id!] ?? [];
|
|
for (final f in folders) {
|
|
if (f.kind == 'sent') {
|
|
final pwd = await SecureStore.readPassword(account.email);
|
|
if (pwd != null) {
|
|
try {
|
|
await _service.syncFolder(account, pwd, f.name);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
await loadEmails();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> saveDraft({
|
|
required List<MailAddress> to,
|
|
List<MailAddress> cc = const [],
|
|
required String subject,
|
|
String? textBody,
|
|
}) async {
|
|
final account = currentAccount;
|
|
if (account == null) return;
|
|
final password = await SecureStore.readPassword(account.email);
|
|
if (password == null) return;
|
|
await _service.saveDraft(
|
|
account,
|
|
password,
|
|
to: to,
|
|
cc: cc,
|
|
subject: subject,
|
|
textBody: textBody,
|
|
);
|
|
await loadEmails();
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 搜索
|
|
Future<List<Email>> search(String query, {bool remote = true}) async {
|
|
final account = currentAccount;
|
|
if (account == null) return [];
|
|
final local = await _emailStore.searchLocal(account.id!, query);
|
|
if (!remote) return local;
|
|
final password = await SecureStore.readPassword(account.email);
|
|
if (password == null) return local;
|
|
try {
|
|
final folder = currentFolder ?? 'INBOX';
|
|
final remote = await _service.searchRemote(account, password, query,
|
|
folder: folder);
|
|
// 合并去重
|
|
final seen = <String>{};
|
|
final merged = <Email>[...remote, ...local];
|
|
final result = <Email>[];
|
|
for (final e in merged) {
|
|
final key = '${e.folder}:${e.uid}';
|
|
if (seen.add(key)) result.add(e);
|
|
}
|
|
return result;
|
|
} catch (_) {
|
|
return local;
|
|
}
|
|
}
|
|
|
|
// ---- 偏好设置 ----
|
|
|
|
Future<bool> getDarkMode() async =>
|
|
await PrefStore.getBool('dark_mode', def: false);
|
|
|
|
Future<void> setDarkMode(bool v) {
|
|
darkMode.value = v;
|
|
return PrefStore.setBool('dark_mode', v);
|
|
}
|
|
|
|
Future<int> getSyncInterval() async =>
|
|
await PrefStore.getInt('sync_interval', def: 5);
|
|
|
|
Future<void> setSyncInterval(int minutes) =>
|
|
PrefStore.setInt('sync_interval', minutes);
|
|
}
|