Initial Flutter/Dart port of SharedInbox

IMAP/SMTP email client with offline-first architecture:
sync engine writes to Drift (SQLite), UI reads reactively
from the local DB. enough_mail vendored under packages/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-16 07:35:56 +02:00
co-authored by Claude Sonnet 4.6
commit 5ebda521d6
28 changed files with 1893 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Flutter/Dart
.dart_tool/
.packages
pubspec.lock
build/
*.g.dart
*.freezed.dart
# IDE
.idea/
.vscode/
*.iml
# OS
.DS_Store
Thumbs.db
# Android
android/.gradle/
android/local.properties
android/app/google-services.json
# iOS / macOS
ios/Pods/
macos/Pods/
*.xcworkspace/
+37
View File
@@ -0,0 +1,37 @@
# SharedInbox Flutter — Agent Guide
## Code conventions
- No `if` chains where a pattern/match or data-driven approach works.
- Fail loudly — `throw StateError(...)` beats silent fallbacks.
- New source files go under `lib/core/` (interfaces/models), `lib/data/` (implementations), or `lib/ui/` (screens/widgets).
## Drift (DB)
- Schema in `lib/data/db/database.dart`.
- After any schema change run: `dart run build_runner build --delete-conflicting-outputs`
- Generated `database.g.dart` is committed — do not hand-edit it.
## enough_mail (vendored)
- Located at `packages/enough_mail/` — edit freely.
- IMAP client helpers are in `lib/data/imap/imap_client_factory.dart`.
## Running
```bash
# Code generation (must run after schema changes)
dart run build_runner build --delete-conflicting-outputs
# Desktop
flutter run -d linux
# Tests
flutter test
```
## Adding a screen
1. Create `lib/ui/screens/my_screen.dart`.
2. Add a `GoRoute` in `lib/ui/router.dart`.
3. No separate ViewModel file needed — use `ConsumerWidget` / `ConsumerStatefulWidget` directly with Riverpod providers.
+38
View File
@@ -0,0 +1,38 @@
# SharedInbox Flutter — Plan
## Architecture
```
IMAP/SMTP server
AccountSyncManager (IMAP IDLE per account)
↓ writes
Drift (SQLite, local DB)
↓ reads
UI (Riverpod + go_router)
```
UI never touches the network. The sync layer runs independently.
## Phases
| Phase | Scope | Status |
|---|---|---|
| 0 — Scaffold | pubspec, Drift schema, DI, router, enough_mail vendored | Done |
| 1 — Core models | `Account`, `Mailbox`, `Email`, `EmailBody`, repository interfaces | Done |
| 2 — DB layer | Drift tables, `AccountRepositoryImpl`, `MailboxRepositoryImpl`, `EmailRepositoryImpl` | Done |
| 3 — IMAP sync | `connectImap`, `MailboxRepositoryImpl.syncMailboxes`, `EmailRepositoryImpl.syncEmails` | Done |
| 4 — IMAP IDLE | `AccountSyncManager` with exponential-backoff reconnect | Done |
| 5 — SMTP send | `connectSmtp`, `EmailRepositoryImpl.sendEmail` | Done |
| 6 — UI | All screens: AccountList, AddAccount, MailboxList, EmailList, EmailDetail, Compose, Settings | Done |
| 7 — Code-gen | Run `dart run build_runner build` to generate `database.g.dart` | Pending |
| 8 — Platform targets | Android, iOS, Linux, macOS, Windows entry points | Pending |
| 9 — Polish | Reply prefill, attachment open, thread view, search | Next |
## Next candidates
- Reply-with-prefill (subject/body/from populated from original email)
- Thread view (group by `References` / `In-Reply-To`)
- Search (IMAP `SEARCH` command)
- Attachment download + open
- Draft auto-save
+45
View File
@@ -0,0 +1,45 @@
# SharedInbox [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](packages/enough_mail/LICENSE)
IMAP/SMTP email client written in [Flutter](https://flutter.dev).
Targets **Android, iOS, and Desktop** (Linux, macOS, Windows).
Supports **multiple accounts** — each synced independently via IMAP IDLE.
## Design philosophy: offline-first
```
IMAP/SMTP server
AccountSyncManager ←→ Drift (SQLite, local DB)
UI (reads only from DB)
```
The UI never touches the network. The sync engine runs in the background and writes to a local Drift database. Screens observe reactive streams from that DB.
## Key packages
| Package | Role |
|---|---|
| `enough_mail` (vendored in `packages/`) | IMAP / SMTP / MIME |
| `drift` | Local SQLite ORM |
| `flutter_riverpod` | State management / DI |
| `go_router` | Navigation |
## Building
```bash
# Install dependencies and run code generation
dart pub get
dart run build_runner build --delete-conflicting-outputs
# Run on desktop
flutter run -d linux # or macos / windows
# Run on Android / iOS
flutter run
```
## Vendored enough_mail
`packages/enough_mail/` is a local copy of [enough_mail](https://github.com/Enough-Software/enough_mail) so it can be modified if needed. It is referenced via a `path:` dependency in `pubspec.yaml`.
+24
View File
@@ -0,0 +1,24 @@
/// Represents a configured IMAP/SMTP account stored in the local DB.
class Account {
final String id;
final String displayName;
final String email;
final String imapHost;
final int imapPort;
final bool imapSsl;
final String smtpHost;
final int smtpPort;
final bool smtpSsl;
const Account({
required this.id,
required this.displayName,
required this.email,
required this.imapHost,
required this.imapPort,
required this.imapSsl,
required this.smtpHost,
required this.smtpPort,
required this.smtpSsl,
});
}
+88
View File
@@ -0,0 +1,88 @@
/// Email header — stored locally after sync, body fetched on demand.
class Email {
final String id; // "<accountId>:<uid>"
final String accountId;
final String mailboxPath;
final int uid;
final String? subject;
final DateTime? sentAt;
final DateTime receivedAt;
final List<EmailAddress> from;
final List<EmailAddress> to;
final List<EmailAddress> cc;
final String? preview;
final bool isSeen;
final bool isFlagged;
final bool hasAttachment;
const Email({
required this.id,
required this.accountId,
required this.mailboxPath,
required this.uid,
this.subject,
this.sentAt,
required this.receivedAt,
required this.from,
required this.to,
required this.cc,
this.preview,
required this.isSeen,
required this.isFlagged,
required this.hasAttachment,
});
}
class EmailAddress {
final String? name;
final String email;
const EmailAddress({this.name, required this.email});
@override
String toString() => name != null ? '$name <$email>' : email;
}
/// Full message body — fetched on demand, cached in the local DB.
class EmailBody {
final String emailId;
final String? textBody;
final String? htmlBody;
final List<EmailAttachment> attachments;
const EmailBody({
required this.emailId,
this.textBody,
this.htmlBody,
required this.attachments,
});
}
class EmailAttachment {
final String filename;
final String contentType;
final int size;
const EmailAttachment({
required this.filename,
required this.contentType,
required this.size,
});
}
/// Outgoing email — used for compose / reply.
class EmailDraft {
final EmailAddress from;
final List<EmailAddress> to;
final List<EmailAddress> cc;
final String subject;
final String body;
const EmailDraft({
required this.from,
required this.to,
required this.cc,
required this.subject,
required this.body,
});
}
+18
View File
@@ -0,0 +1,18 @@
/// A mailbox / folder within an account (maps to an IMAP mailbox).
class Mailbox {
final String id; // "<accountId>:<path>"
final String accountId;
final String path; // e.g. "INBOX", "Sent", "INBOX/Work"
final String name; // last path component
final int unreadCount;
final int totalCount;
const Mailbox({
required this.id,
required this.accountId,
required this.path,
required this.name,
required this.unreadCount,
required this.totalCount,
});
}
@@ -0,0 +1,9 @@
import '../models/account.dart';
abstract class AccountRepository {
Stream<List<Account>> observeAccounts();
Future<Account?> getAccount(String id);
Future<void> addAccount(Account account, String password);
Future<void> removeAccount(String id);
Future<String> getPassword(String accountId);
}
@@ -0,0 +1,15 @@
import '../models/email.dart';
abstract class EmailRepository {
Stream<List<Email>> observeEmails(String accountId, String mailboxPath);
Future<EmailBody> getEmailBody(String emailId);
Future<void> syncEmails(String accountId, String mailboxPath);
Future<void> setFlag(
String emailId, {
bool? seen,
bool? flagged,
});
Future<void> moveEmail(String emailId, String destMailboxPath);
Future<void> deleteEmail(String emailId);
Future<void> sendEmail(String accountId, EmailDraft draft);
}
@@ -0,0 +1,6 @@
import '../models/mailbox.dart';
abstract class MailboxRepository {
Stream<List<Mailbox>> observeMailboxes(String accountId);
Future<void> syncMailboxes(String accountId);
}
+117
View File
@@ -0,0 +1,117 @@
import 'dart:async';
import 'package:enough_mail/enough_mail.dart' as imap;
import '../models/account.dart';
import '../repositories/account_repository.dart';
import '../repositories/email_repository.dart';
import '../repositories/mailbox_repository.dart';
import '../../data/imap/imap_client_factory.dart';
/// Manages one IMAP IDLE connection per account.
/// On new message notification it triggers a sync and notifies listeners.
class AccountSyncManager {
AccountSyncManager(this._accounts, this._mailboxes, this._emails);
final AccountRepository _accounts;
final MailboxRepository _mailboxes;
final EmailRepository _emails;
final Map<String, _AccountSync> _active = {};
Future<void> start() async {
_accounts.observeAccounts().listen((accounts) {
final ids = accounts.map((a) => a.id).toSet();
// Start new
for (final account in accounts) {
if (!_active.containsKey(account.id)) {
_start(account);
}
}
// Stop removed
for (final id in _active.keys.toList()) {
if (!ids.contains(id)) {
_active.remove(id)?.stop();
}
}
});
}
void _start(Account account) {
final sync = _AccountSync(account, _accounts, _mailboxes, _emails);
_active[account.id] = sync;
sync.start();
}
void dispose() {
for (final s in _active.values) {
s.stop();
}
_active.clear();
}
}
class _AccountSync {
_AccountSync(this.account, this._accounts, this._mailboxes, this._emails);
final Account account;
final AccountRepository _accounts;
final MailboxRepository _mailboxes;
final EmailRepository _emails;
imap.ImapClient? _idleClient;
bool _running = false;
int _backoffSeconds = 5;
void start() {
_running = true;
_loop();
}
void stop() {
_running = false;
_idleClient?.logout().ignore();
_idleClient = null;
}
Future<void> _loop() async {
while (_running) {
try {
await _sync();
await _idle();
_backoffSeconds = 5;
} catch (_) {
await Future.delayed(Duration(seconds: _backoffSeconds));
_backoffSeconds = (_backoffSeconds * 2).clamp(5, 300);
}
}
}
Future<void> _sync() async {
await _mailboxes.syncMailboxes(account.id);
await _emails.syncEmails(account.id, 'INBOX');
}
Future<void> _idle() async {
if (!_running) return;
final password = await _accounts.getPassword(account.id);
final client = await connectImap(account, password);
_idleClient = client;
await client.selectMailboxByPath('INBOX');
final idleDone = Completer<void>();
await client.idleStart();
client.eventBus
.on<imap.ImapEvent>()
.where((e) => e is imap.ImapMessagesExistEvent)
.first
.then((_) => idleDone.complete());
// Stop idle after 25 minutes to stay within server limits
Future.delayed(const Duration(minutes: 25)).then((_) {
if (!idleDone.isCompleted) idleDone.complete();
});
await idleDone.future;
await client.idleDone();
await client.logout();
_idleClient = null;
}
}
+91
View File
@@ -0,0 +1,91 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
part 'database.g.dart';
// ── Tables ────────────────────────────────────────────────────────────────────
class Accounts extends Table {
TextColumn get id => text()();
TextColumn get displayName => text()();
TextColumn get email => text()();
TextColumn get imapHost => text()();
IntColumn get imapPort => integer()();
BoolColumn get imapSsl => boolean()();
TextColumn get smtpHost => text()();
IntColumn get smtpPort => integer()();
BoolColumn get smtpSsl => boolean()();
@override
Set<Column> get primaryKey => {id};
}
class Mailboxes extends Table {
TextColumn get id => text()();
TextColumn get accountId =>
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
TextColumn get path => text()();
TextColumn get name => text()();
IntColumn get unreadCount => integer().withDefault(const Constant(0))();
IntColumn get totalCount => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {id};
}
class Emails extends Table {
TextColumn get id => text()();
TextColumn get accountId =>
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
TextColumn get mailboxPath => text()();
IntColumn get uid => integer()();
TextColumn get subject => text().nullable()();
DateTimeColumn get sentAt => dateTime().nullable()();
DateTimeColumn get receivedAt => dateTime()();
// JSON-encoded List<{name,email}>
TextColumn get fromJson => text().withDefault(const Constant('[]'))();
TextColumn get toJson => text().withDefault(const Constant('[]'))();
TextColumn get ccJson => text().withDefault(const Constant('[]'))();
TextColumn get preview => text().nullable()();
BoolColumn get isSeen => boolean().withDefault(const Constant(false))();
BoolColumn get isFlagged => boolean().withDefault(const Constant(false))();
BoolColumn get hasAttachment => boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {id};
}
class EmailBodies extends Table {
TextColumn get emailId =>
text().references(Emails, #id, onDelete: KeyAction.cascade)();
TextColumn get textBody => text().nullable()();
TextColumn get htmlBody => text().nullable()();
// JSON-encoded List<{filename,contentType,size}>
TextColumn get attachmentsJson =>
text().withDefault(const Constant('[]'))();
@override
Set<Column> get primaryKey => {emailId};
}
// ── Database ──────────────────────────────────────────────────────────────────
@DriftDatabase(tables: [Accounts, Mailboxes, Emails, EmailBodies])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'sharedinbox.db'));
return NativeDatabase.createInBackground(file);
});
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:enough_mail/enough_mail.dart';
import '../../core/models/account.dart';
/// Opens an authenticated IMAP client for the given account.
Future<ImapClient> connectImap(Account account, String password) async {
final client = ImapClient(isLogEnabled: false);
await client.connectToServer(
account.imapHost,
account.imapPort,
isSecure: account.imapSsl,
);
await client.login(account.email, password);
return client;
}
/// Opens an authenticated SMTP client for the given account.
Future<SmtpClient> connectSmtp(Account account, String password) async {
final client = SmtpClient(account.displayName, isLogEnabled: false);
await client.connectToServer(
account.smtpHost,
account.smtpPort,
isSecure: account.smtpSsl,
);
await client.ehlo();
if (!account.smtpSsl) {
await client.startTls();
}
await client.authenticate(account.email, password, AuthMechanism.plain);
return client;
}
@@ -0,0 +1,75 @@
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../core/models/account.dart';
import '../../core/repositories/account_repository.dart';
import '../db/database.dart';
import '../db/database.dart' as db show Account;
class AccountRepositoryImpl implements AccountRepository {
AccountRepositoryImpl(this._db);
final AppDatabase _db;
final _storage = const FlutterSecureStorage();
@override
Stream<List<Account>> observeAccounts() {
return _db.select(_db.accounts).watch().map(
(rows) => rows.map(_toModel).toList(),
);
}
@override
Future<Account?> getAccount(String id) async {
final row = await (_db.select(_db.accounts)
..where((t) => t.id.equals(id)))
.getSingleOrNull();
return row == null ? null : _toModel(row);
}
@override
Future<void> addAccount(Account account, String password) async {
await _db.into(_db.accounts).insertOnConflictUpdate(
AccountsCompanion.insert(
id: account.id,
displayName: account.displayName,
email: account.email,
imapHost: account.imapHost,
imapPort: account.imapPort,
imapSsl: account.imapSsl,
smtpHost: account.smtpHost,
smtpPort: account.smtpPort,
smtpSsl: account.smtpSsl,
),
);
await _storage.write(key: _passwordKey(account.id), value: password);
}
@override
Future<void> removeAccount(String id) async {
await (_db.delete(_db.accounts)..where((t) => t.id.equals(id))).go();
await _storage.delete(key: _passwordKey(id));
}
@override
Future<String> getPassword(String accountId) async {
final pw = await _storage.read(key: _passwordKey(accountId));
if (pw == null) throw StateError('No password stored for account $accountId');
return pw;
}
String _passwordKey(String accountId) => 'account_password_$accountId';
Account _toModel(db.Account row) => Account(
id: row.id,
displayName: row.displayName,
email: row.email,
imapHost: row.imapHost,
imapPort: row.imapPort,
imapSsl: row.imapSsl,
smtpHost: row.smtpHost,
smtpPort: row.smtpPort,
smtpSsl: row.smtpSsl,
);
}
@@ -0,0 +1,330 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import '../../core/models/email.dart';
import '../../core/repositories/email_repository.dart';
import '../db/database.dart';
import '../db/database.dart' as db show Email, EmailBody;
import '../imap/imap_client_factory.dart';
import '../../core/repositories/account_repository.dart';
class EmailRepositoryImpl implements EmailRepository {
EmailRepositoryImpl(this._db, this._accounts);
final AppDatabase _db;
final AccountRepository _accounts;
// ── Observe ────────────────────────────────────────────────────────────────
@override
Stream<List<Email>> observeEmails(String accountId, String mailboxPath) {
return (_db.select(_db.emails)
..where(
(t) =>
t.accountId.equals(accountId) &
t.mailboxPath.equals(mailboxPath),
)
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)]))
.watch()
.map((rows) => rows.map(_toModel).toList());
}
// ── Body (on-demand) ───────────────────────────────────────────────────────
@override
Future<EmailBody> getEmailBody(String emailId) async {
final cached = await (_db.select(_db.emailBodies)
..where((t) => t.emailId.equals(emailId)))
.getSingleOrNull();
if (cached != null) return _toBodyModel(cached);
final emailRow = await (_db.select(_db.emails)
..where((t) => t.id.equals(emailId)))
.getSingle();
final account = (await _accounts.getAccount(emailRow.accountId))!;
final password = await _accounts.getPassword(account.id);
final client = await connectImap(account, password);
try {
await client.selectMailbox(
imap.Mailbox.fromStatus(
imap.MailboxStatus(emailRow.mailboxPath, 0, 0, 0),
),
);
final fetch = await client.fetchMessage(
imap.MessageSequence.fromId(emailRow.uid, isUid: true),
'(RFC822)',
);
final msg = fetch.messages.first;
final mime = imap.MimeMessage.parseFromData(msg.rawData!);
final textBody = mime.decodeTextPlainPart();
final htmlBody = mime.decodeTextHtmlPart();
final attachments = mime.findContentInfo(
disposition: imap.ContentDisposition.attachment,
);
final attachmentsJson = jsonEncode(
attachments
.map(
(a) => {
'filename': a.fileName ?? '',
'contentType': a.contentType?.mediaType.text ?? '',
'size': a.size ?? 0,
},
)
.toList(),
);
await _db.into(_db.emailBodies).insertOnConflictUpdate(
EmailBodiesCompanion.insert(
emailId: emailId,
textBody: Value(textBody),
htmlBody: Value(htmlBody),
attachmentsJson: Value(attachmentsJson),
),
);
return _toBodyModel(
EmailBody(
emailId: emailId,
textBody: textBody,
htmlBody: htmlBody,
attachments: _parseAttachments(attachmentsJson),
),
);
} finally {
await client.logout();
}
}
// ── Sync ───────────────────────────────────────────────────────────────────
@override
Future<void> syncEmails(String accountId, String mailboxPath) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
final client = await connectImap(account, password);
try {
final mailbox = await client.selectMailboxByPath(mailboxPath);
final sequence = imap.MessageSequence.fromAll();
final messages = await client.fetchMessages(
sequence,
'(UID FLAGS ENVELOPE BODYSTRUCTURE)',
);
for (final msg in messages.messages) {
final mime = msg.envelope;
if (mime == null) continue;
final emailId = '${accountId}:${msg.uid}';
await _db.into(_db.emails).insertOnConflictUpdate(
EmailsCompanion.insert(
id: emailId,
accountId: accountId,
mailboxPath: mailboxPath,
uid: Value(msg.uid ?? 0),
subject: Value(mime.subject),
sentAt: Value(mime.date),
receivedAt: Value(mime.date ?? DateTime.now()),
fromJson: Value(
jsonEncode(
mime.from
?.map((a) => {
'name': a.personalName,
'email': a.email ?? '',
})
.toList() ??
[],
),
),
toJson: Value(
jsonEncode(
mime.to
?.map((a) => {
'name': a.personalName,
'email': a.email ?? '',
})
.toList() ??
[],
),
),
ccJson: Value(
jsonEncode(
mime.cc
?.map((a) => {
'name': a.personalName,
'email': a.email ?? '',
})
.toList() ??
[],
),
),
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
isFlagged: Value(msg.flags?.contains(r'\Flagged') ?? false),
hasAttachment: Value(msg.body?.hasAttachments ?? false),
),
);
}
} finally {
await client.logout();
}
}
// ── Mutations ──────────────────────────────────────────────────────────────
@override
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {
final row = await (_db.select(_db.emails)
..where((t) => t.id.equals(emailId)))
.getSingle();
final account = (await _accounts.getAccount(row.accountId))!;
final password = await _accounts.getPassword(account.id);
final client = await connectImap(account, password);
try {
await client.selectMailboxByPath(row.mailboxPath);
final seq =
imap.MessageSequence.fromId(row.uid, isUid: true);
if (seen != null) {
seen
? await client.markSeen(seq, isUidSequence: true)
: await client.markUnseen(seq, isUidSequence: true);
}
if (flagged != null) {
flagged
? await client.markFlagged(seq, isUidSequence: true)
: await client.markUnflagged(seq, isUidSequence: true);
}
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId)))
.write(
EmailsCompanion(
isSeen: seen != null ? Value(seen) : const Value.absent(),
isFlagged: flagged != null ? Value(flagged) : const Value.absent(),
),
);
} finally {
await client.logout();
}
}
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {
final row = await (_db.select(_db.emails)
..where((t) => t.id.equals(emailId)))
.getSingle();
final account = (await _accounts.getAccount(row.accountId))!;
final password = await _accounts.getPassword(account.id);
final client = await connectImap(account, password);
try {
await client.selectMailboxByPath(row.mailboxPath);
await client.move(
imap.MessageSequence.fromId(row.uid, isUid: true),
targetMailboxPath: destMailboxPath,
isUidSequence: true,
);
await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go();
} finally {
await client.logout();
}
}
@override
Future<void> deleteEmail(String emailId) async {
final row = await (_db.select(_db.emails)
..where((t) => t.id.equals(emailId)))
.getSingle();
final account = (await _accounts.getAccount(row.accountId))!;
final password = await _accounts.getPassword(account.id);
final client = await connectImap(account, password);
try {
await client.selectMailboxByPath(row.mailboxPath);
await client.deleteMessages(
imap.MessageSequence.fromId(row.uid, isUid: true),
isUidSequence: true,
);
await (_db.delete(_db.emails)..where((t) => t.id.equals(emailId))).go();
} finally {
await client.logout();
}
}
@override
Future<void> sendEmail(String accountId, EmailDraft draft) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
final smtpClient = await connectSmtp(account, password);
try {
final builder = imap.MessageBuilder()
..from = [
imap.MailAddress(draft.from.name, draft.from.email),
]
..to = draft.to
.map((a) => imap.MailAddress(a.name, a.email))
.toList()
..cc = draft.cc
.map((a) => imap.MailAddress(a.name, a.email))
.toList()
..subject = draft.subject
..text = draft.body;
final message = builder.buildMimeMessage();
await smtpClient.sendMessage(message);
} finally {
smtpClient.disconnect();
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
Email _toModel(db.Email row) {
List<EmailAddress> parseAddresses(String json) {
final list = jsonDecode(json) as List;
return list
.map(
(e) => EmailAddress(
name: e['name'] as String?,
email: e['email'] as String,
),
)
.toList();
}
return Email(
id: row.id,
accountId: row.accountId,
mailboxPath: row.mailboxPath,
uid: row.uid,
subject: row.subject,
sentAt: row.sentAt,
receivedAt: row.receivedAt,
from: parseAddresses(row.fromJson),
to: parseAddresses(row.toJson),
cc: parseAddresses(row.ccJson),
preview: row.preview,
isSeen: row.isSeen,
isFlagged: row.isFlagged,
hasAttachment: row.hasAttachment,
);
}
EmailBody _toBodyModel(dynamic row) {
if (row is db.EmailBody) {
return EmailBody(
emailId: row.emailId,
textBody: row.textBody,
htmlBody: row.htmlBody,
attachments: _parseAttachments(row.attachmentsJson),
);
}
return row as EmailBody;
}
List<EmailAttachment> _parseAttachments(String json) {
final list = jsonDecode(json) as List;
return list
.map(
(e) => EmailAttachment(
filename: e['filename'] as String,
contentType: e['contentType'] as String,
size: e['size'] as int,
),
)
.toList();
}
}
@@ -0,0 +1,60 @@
import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import '../../core/models/mailbox.dart';
import '../../core/repositories/account_repository.dart';
import '../../core/repositories/mailbox_repository.dart';
import '../db/database.dart';
import '../db/database.dart' as db show Mailbox;
import '../imap/imap_client_factory.dart';
class MailboxRepositoryImpl implements MailboxRepository {
MailboxRepositoryImpl(this._db, this._accounts);
final AppDatabase _db;
final AccountRepository _accounts;
@override
Stream<List<Mailbox>> observeMailboxes(String accountId) {
return (_db.select(_db.mailboxes)
..where((t) => t.accountId.equals(accountId))
..orderBy([(t) => OrderingTerm.asc(t.path)]))
.watch()
.map((rows) => rows.map(_toModel).toList());
}
@override
Future<void> syncMailboxes(String accountId) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
final client = await connectImap(account, password);
try {
final response = await client.listMailboxes();
for (final mb in response.mailboxes ?? const <imap.Mailbox>[]) {
final path = mb.path ?? mb.name;
final id = '${accountId}:$path';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: id,
accountId: accountId,
path: path,
name: mb.name,
unreadCount: Value(mb.messagesUnseen ?? 0),
totalCount: Value(mb.messagesExists ?? 0),
),
);
}
} finally {
await client.logout();
}
}
Mailbox _toModel(db.Mailbox row) => Mailbox(
id: row.id,
accountId: row.accountId,
path: row.path,
name: row.name,
unreadCount: row.unreadCount,
totalCount: row.totalCount,
);
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/repositories/account_repository.dart';
import 'core/repositories/email_repository.dart';
import 'core/repositories/mailbox_repository.dart';
import 'core/sync/account_sync_manager.dart';
import 'data/db/database.dart';
import 'data/repositories/account_repository_impl.dart';
import 'data/repositories/email_repository_impl.dart';
import 'data/repositories/mailbox_repository_impl.dart';
final dbProvider = Provider<AppDatabase>((ref) {
final db = AppDatabase();
ref.onDispose(db.close);
return db;
});
final accountRepositoryProvider = Provider<AccountRepository>((ref) {
return AccountRepositoryImpl(ref.watch(dbProvider));
});
final mailboxRepositoryProvider = Provider<MailboxRepository>((ref) {
return MailboxRepositoryImpl(
ref.watch(dbProvider),
ref.watch(accountRepositoryProvider),
);
});
final emailRepositoryProvider = Provider<EmailRepository>((ref) {
return EmailRepositoryImpl(
ref.watch(dbProvider),
ref.watch(accountRepositoryProvider),
);
});
final syncManagerProvider = Provider<AccountSyncManager>((ref) {
final manager = AccountSyncManager(
ref.watch(accountRepositoryProvider),
ref.watch(mailboxRepositoryProvider),
ref.watch(emailRepositoryProvider),
);
ref.onDispose(manager.dispose);
return manager;
});
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'di.dart';
import 'ui/router.dart';
void main() {
runApp(const ProviderScope(child: SharedInboxApp()));
}
class SharedInboxApp extends ConsumerWidget {
const SharedInboxApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Start background sync
ref.watch(syncManagerProvider).start();
return MaterialApp.router(
title: 'SharedInbox',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
useMaterial3: true,
),
routerConfig: router,
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:go_router/go_router.dart';
import 'screens/account_list_screen.dart';
import 'screens/add_account_screen.dart';
import 'screens/mailbox_list_screen.dart';
import 'screens/email_list_screen.dart';
import 'screens/email_detail_screen.dart';
import 'screens/compose_screen.dart';
import 'screens/settings_screen.dart';
final router = GoRouter(
initialLocation: '/accounts',
routes: [
GoRoute(
path: '/accounts',
builder: (ctx, state) => const AccountListScreen(),
routes: [
GoRoute(
path: 'add',
builder: (ctx, state) => const AddAccountScreen(),
),
GoRoute(
path: ':accountId/mailboxes',
builder: (ctx, state) =>
MailboxListScreen(accountId: state.pathParameters['accountId']!),
routes: [
GoRoute(
path: ':mailboxPath/emails',
builder: (ctx, state) => EmailListScreen(
accountId: state.pathParameters['accountId']!,
mailboxPath: state.pathParameters['mailboxPath']!,
),
routes: [
GoRoute(
path: ':emailId',
builder: (ctx, state) => EmailDetailScreen(
emailId: state.pathParameters['emailId']!,
),
),
],
),
],
),
],
),
GoRoute(
path: '/compose',
builder: (ctx, state) {
final extra = state.extra as Map<String, dynamic>?;
return ComposeScreen(
accountId: extra?['accountId'] as String?,
replyToEmailId: extra?['replyToEmailId'] as String?,
prefillTo: extra?['prefillTo'] as String?,
prefillSubject: extra?['prefillSubject'] as String?,
prefillBody: extra?['prefillBody'] as String?,
);
},
),
GoRoute(
path: '/settings',
builder: (ctx, state) => const SettingsScreen(),
),
],
);
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../di.dart';
class AccountListScreen extends ConsumerWidget {
const AccountListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final accountsAsync = ref.watch(
accountRepositoryProvider.select(
(r) => r.observeAccounts(),
).stream,
);
return Scaffold(
appBar: AppBar(
title: const Text('SharedInbox'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => context.push('/settings'),
),
],
),
body: StreamBuilder(
stream: ref.watch(accountRepositoryProvider).observeAccounts(),
builder: (ctx, snap) {
if (!snap.hasData) return const Center(child: CircularProgressIndicator());
final accounts = snap.data!;
if (accounts.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('No accounts yet.'),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () => context.push('/accounts/add'),
icon: const Icon(Icons.add),
label: const Text('Add account'),
),
],
),
);
}
return ListView.builder(
itemCount: accounts.length,
itemBuilder: (ctx, i) {
final a = accounts[i];
return ListTile(
leading: const Icon(Icons.account_circle),
title: Text(a.displayName),
subtitle: Text(a.email),
onTap: () =>
context.push('/accounts/${a.id}/mailboxes'),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/accounts/add'),
child: const Icon(Icons.add),
),
);
}
}
+138
View File
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/models/account.dart';
import '../../di.dart';
class AddAccountScreen extends ConsumerStatefulWidget {
const AddAccountScreen({super.key});
@override
ConsumerState<AddAccountScreen> createState() => _AddAccountScreenState();
}
class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
final _form = GlobalKey<FormState>();
final _displayName = TextEditingController();
final _email = TextEditingController();
final _password = TextEditingController();
final _imapHost = TextEditingController();
final _imapPort = TextEditingController(text: '993');
bool _imapSsl = true;
final _smtpHost = TextEditingController();
final _smtpPort = TextEditingController(text: '587');
bool _smtpSsl = false;
bool _saving = false;
@override
void dispose() {
for (final c in [
_displayName,
_email,
_password,
_imapHost,
_imapPort,
_smtpHost,
_smtpPort,
]) {
c.dispose();
}
super.dispose();
}
Future<void> _save() async {
if (!_form.currentState!.validate()) return;
setState(() => _saving = true);
try {
final account = Account(
id: DateTime.now().millisecondsSinceEpoch.toString(),
displayName: _displayName.text.trim(),
email: _email.text.trim(),
imapHost: _imapHost.text.trim(),
imapPort: int.parse(_imapPort.text),
imapSsl: _imapSsl,
smtpHost: _smtpHost.text.trim(),
smtpPort: int.parse(_smtpPort.text),
smtpSsl: _smtpSsl,
);
await ref
.read(accountRepositoryProvider)
.addAccount(account, _password.text);
if (mounted) context.pop();
} catch (e) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Error: $e')));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Add account')),
body: Form(
key: _form,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_field(_displayName, 'Display name'),
_field(_email, 'Email address', keyboardType: TextInputType.emailAddress),
_field(_password, 'Password', obscure: true),
const Divider(),
const Text('IMAP', style: TextStyle(fontWeight: FontWeight.bold)),
_field(_imapHost, 'IMAP host'),
_field(_imapPort, 'Port', keyboardType: TextInputType.number),
SwitchListTile(
title: const Text('SSL/TLS'),
value: _imapSsl,
onChanged: (v) => setState(() => _imapSsl = v),
),
const Divider(),
const Text('SMTP', style: TextStyle(fontWeight: FontWeight.bold)),
_field(_smtpHost, 'SMTP host'),
_field(_smtpPort, 'Port', keyboardType: TextInputType.number),
SwitchListTile(
title: const Text('SSL/TLS'),
value: _smtpSsl,
onChanged: (v) => setState(() => _smtpSsl = v),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
),
],
),
),
);
}
Widget _field(
TextEditingController ctrl,
String label, {
bool obscure = false,
TextInputType? keyboardType,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextFormField(
controller: ctrl,
obscureText: obscure,
keyboardType: keyboardType,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
);
}
}
+147
View File
@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/models/email.dart';
import '../../di.dart';
class ComposeScreen extends ConsumerStatefulWidget {
const ComposeScreen({
super.key,
this.accountId,
this.replyToEmailId,
this.prefillTo,
this.prefillSubject,
this.prefillBody,
});
final String? accountId;
final String? replyToEmailId;
final String? prefillTo;
final String? prefillSubject;
final String? prefillBody;
@override
ConsumerState<ComposeScreen> createState() => _ComposeScreenState();
}
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
final _to = TextEditingController();
final _cc = TextEditingController();
final _subject = TextEditingController();
final _body = TextEditingController();
String? _accountId;
bool _sending = false;
@override
void initState() {
super.initState();
if (widget.prefillTo != null) _to.text = widget.prefillTo!;
if (widget.prefillSubject != null) _subject.text = widget.prefillSubject!;
if (widget.prefillBody != null) _body.text = widget.prefillBody!;
_accountId = widget.accountId;
}
@override
void dispose() {
for (final c in [_to, _cc, _subject, _body]) {
c.dispose();
}
super.dispose();
}
Future<void> _send() async {
if (_accountId == null) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Select an account first')));
return;
}
setState(() => _sending = true);
try {
final account =
(await ref.read(accountRepositoryProvider).getAccount(_accountId!))!;
final draft = EmailDraft(
from: EmailAddress(name: account.displayName, email: account.email),
to: _to.text
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.map((e) => EmailAddress(email: e))
.toList(),
cc: _cc.text
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.map((e) => EmailAddress(email: e))
.toList(),
subject: _subject.text,
body: _body.text,
);
await ref.read(emailRepositoryProvider).sendEmail(_accountId!, draft);
if (mounted) context.pop();
} catch (e) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Send failed: $e')));
} finally {
if (mounted) setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Compose'),
actions: [
IconButton(
icon: _sending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
onPressed: _sending ? null : _send,
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_field(_to, 'To', keyboardType: TextInputType.emailAddress),
_field(_cc, 'Cc', keyboardType: TextInputType.emailAddress),
_field(_subject, 'Subject'),
const SizedBox(height: 8),
TextFormField(
controller: _body,
maxLines: null,
minLines: 10,
decoration: const InputDecoration(
labelText: 'Body',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
),
],
),
);
}
Widget _field(
TextEditingController ctrl,
String label, {
TextInputType? keyboardType,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextFormField(
controller: ctrl,
keyboardType: keyboardType,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
+140
View File
@@ -0,0 +1,140 @@
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 '../../core/models/email.dart';
import '../../di.dart';
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
class EmailDetailScreen extends ConsumerStatefulWidget {
const EmailDetailScreen({super.key, required this.emailId});
final String emailId;
@override
ConsumerState<EmailDetailScreen> createState() => _EmailDetailScreenState();
}
class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
late Future<EmailBody> _bodyFuture;
@override
void initState() {
super.initState();
_loadBody();
_markSeen();
}
void _loadBody() {
_bodyFuture =
ref.read(emailRepositoryProvider).getEmailBody(widget.emailId);
}
Future<void> _markSeen() async {
await ref
.read(emailRepositoryProvider)
.setFlag(widget.emailId, seen: true);
}
@override
Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Email'),
actions: [
IconButton(
icon: const Icon(Icons.reply),
tooltip: 'Reply',
onPressed: () => _reply(context, replyAll: false),
),
IconButton(
icon: const Icon(Icons.reply_all),
tooltip: 'Reply all',
onPressed: () => _reply(context, replyAll: true),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
await repo.deleteEmail(widget.emailId);
if (context.mounted) context.pop();
},
),
],
),
body: FutureBuilder<EmailBody>(
future: _bodyFuture,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(child: Text('Error: ${snap.error}'));
}
final body = snap.data!;
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildHeader(ctx, body),
const Divider(),
SelectableText(
body.textBody ??
_htmlToPlain(body.htmlBody ?? '') ,
),
if (body.attachments.isNotEmpty) ...[
const Divider(),
const Text(
'Attachments',
style: TextStyle(fontWeight: FontWeight.bold),
),
for (final att in body.attachments)
ListTile(
leading: const Icon(Icons.attach_file),
title: Text(att.filename),
subtitle: Text(_fmtSize(att.size)),
),
],
],
);
},
),
);
}
Widget _buildHeader(BuildContext ctx, EmailBody body) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
body.emailId, // we'd look up subject from DB in real code
style: Theme.of(ctx).textTheme.titleMedium,
),
const SizedBox(height: 8),
],
);
}
void _reply(BuildContext context, {required bool replyAll}) {
context.push('/compose', extra: {
'replyToEmailId': widget.emailId,
});
}
String _htmlToPlain(String html) {
return html
.replaceAll(RegExp(r'<br\s*/?>'), '\n')
.replaceAll(RegExp(r'<[^>]+>'), '')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&nbsp;', ' ');
}
String _fmtSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
+88
View File
@@ -0,0 +1,88 @@
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 '../../di.dart';
final _dateFmt = DateFormat('MMM d');
class EmailListScreen extends ConsumerWidget {
const EmailListScreen({
super.key,
required this.accountId,
required this.mailboxPath,
});
final String accountId;
final String mailboxPath;
@override
Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(emailRepositoryProvider);
return Scaffold(
appBar: AppBar(
title: Text(mailboxPath),
actions: [
IconButton(
icon: const Icon(Icons.sync),
onPressed: () =>
repo.syncEmails(accountId, mailboxPath),
),
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => context.push(
'/compose',
extra: {'accountId': accountId},
),
),
],
),
body: StreamBuilder(
stream: repo.observeEmails(accountId, mailboxPath),
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
final emails = snap.data!;
if (emails.isEmpty) {
return const Center(child: Text('No 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/$accountId/mailboxes/${Uri.encodeComponent(mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
),
);
},
);
},
),
);
}
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../di.dart';
class MailboxListScreen extends ConsumerWidget {
const MailboxListScreen({super.key, required this.accountId});
final String accountId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(mailboxRepositoryProvider);
return Scaffold(
appBar: AppBar(title: const Text('Mailboxes')),
body: StreamBuilder(
stream: repo.observeMailboxes(accountId),
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(child: CircularProgressIndicator());
}
final mailboxes = snap.data!;
return ListView.builder(
itemCount: mailboxes.length,
itemBuilder: (ctx, i) {
final mb = mailboxes[i];
return ListTile(
leading: const Icon(Icons.folder),
title: Text(mb.name),
trailing: mb.unreadCount > 0
? Badge(label: Text('${mb.unreadCount}'))
: null,
onTap: () => context.push(
'/accounts/$accountId/mailboxes/${Uri.encodeComponent(mb.path)}/emails',
),
);
},
);
},
),
);
}
}
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../di.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(accountRepositoryProvider);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: StreamBuilder(
stream: repo.observeAccounts(),
builder: (ctx, snap) {
final accounts = snap.data ?? [];
return ListView(
children: [
const ListTile(
title: Text(
'Accounts',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final a in accounts)
ListTile(
leading: const Icon(Icons.account_circle),
title: Text(a.displayName),
subtitle: Text(a.email),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
final confirm = await showDialog<bool>(
context: ctx,
builder: (ctx) => AlertDialog(
title: const Text('Remove account?'),
content: Text(
'Remove ${a.displayName}? Local data will be deleted.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Remove'),
),
],
),
);
if (confirm == true) {
await repo.removeAccount(a.id);
}
},
),
),
],
);
},
),
);
}
}
Submodule packages/enough_mail added at 25320adab0
+48
View File
@@ -0,0 +1,48 @@
name: sharedinbox
description: IMAP email client for Android, iOS, and Desktop.
publish_to: none
version: 0.1.0
environment:
sdk: '>=3.3.0 <4.0.0'
flutter: '>=3.22.0'
dependencies:
flutter:
sdk: flutter
# Vendored IMAP/SMTP/MIME library
enough_mail:
path: packages/enough_mail
# Local persistence (offline-first)
drift: ^2.20.3
sqlite3_flutter_libs: ^0.5.28
path_provider: ^2.1.5
path: ^1.9.1
# State management
flutter_riverpod: ^2.6.1
riverpod_annotation: ^2.4.1
# Navigation
go_router: ^14.8.1
# Utilities
freezed_annotation: ^2.4.4
json_annotation: ^4.9.0
intl: any
collection: ^1.19.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
drift_dev: ^2.20.3
riverpod_generator: ^2.6.2
freezed: ^2.5.8
json_serializable: ^6.9.0
build_runner: ^2.4.13
flutter:
uses-material-design: true