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:
+26
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,45 @@
|
||||
# SharedInbox [](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`.
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(' ', ' ');
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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)}',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
+1
Submodule packages/enough_mail added at 25320adab0
@@ -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
|
||||
Reference in New Issue
Block a user