Linting, tests, README, CI, and code quality improvements
Linting:
- analysis_options.yaml: flutter_lints base + 20 additional rules
(prefer_single_quotes, avoid_print, cancel_subscriptions,
unawaited_futures, empty_catches, always_declare_return_types, …)
- unused_import / dead_code treated as errors
pubspec.yaml:
- Remove 6 unused packages: freezed, freezed_annotation, json_annotation,
json_serializable, riverpod_generator, collection
- Add test: ^1.25.0 for pure-Dart unit tests
Testable utilities (extracted from screen code):
- lib/core/utils/html_utils.dart — htmlToPlain()
- lib/core/utils/format_utils.dart — fmtSize()
- lib/core/utils/logger.dart — thin debugPrint wrapper
Unit tests (test/unit/, no device required):
- html_utils_test.dart — 13 cases covering tags, entities, edge cases
- format_utils_test.dart — B / KB / MB / GB formatting
- email_model_test.dart — EmailAddress JSON roundtrip, toString, EmailDraft
- account_sync_backoff_test.dart — exponential backoff + clamping
Taskfile:
- task test → dart test test/unit/ (fast, no device)
- task test-flutter → flutter test (widget tests)
- task check → analyze + unit tests in parallel
README rewrite:
- Covers user flow (download, add account)
- Developer flow: nix develop, direnv allow, task codegen, task check
- Platform status table (Linux done, others pending)
- Project layout diagram
- Schema-change workflow
CI (.github/workflows/ci.yml):
- analyze-and-test job: flutter analyze + dart test on every push/PR
- integration job: Nix + Stalwart on push to main
- build-linux job: flutter build linux --release
Logging:
- Replace all bare catch (_) {} with log() calls
- Sync backoff errors now print account email + retry delay
- STATUS failures on \Noselect mailboxes logged at debug level
- STARTTLS failures logged before continuing without TLS
.gitignore:
- Add .direnv/, .flutter-plugins-dependencies
- Add all platform generated-plugin wiring files
- Add .env / .env.local
- Add linux/build/
.env.example: documents optional STALWART_PORT overrides
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
72e2b599bf
commit
03d35387f7
@@ -0,0 +1,10 @@
|
||||
# Copy this file to .env and fill in values for local overrides.
|
||||
# .env is git-ignored. The Nix flake sets reasonable defaults for all vars below.
|
||||
#
|
||||
# Only needed if you want to run the Stalwart dev server on fixed ports
|
||||
# (e.g. to keep the same port across shell restarts).
|
||||
# Leave unset (or set to 0) to use random ports — the default.
|
||||
|
||||
# STALWART_PORT=8080
|
||||
# STALWART_IMAP_PORT=1430
|
||||
# STALWART_SMTP_PORT=1025
|
||||
@@ -0,0 +1,89 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
analyze-and-test:
|
||||
name: Analyze & unit test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: "3.41.6"
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate Drift code
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze --fatal-infos
|
||||
|
||||
- name: Unit tests
|
||||
run: dart test test/unit/
|
||||
|
||||
integration:
|
||||
name: Integration tests (Stalwart)
|
||||
runs-on: ubuntu-latest
|
||||
# Run integration tests only on push to main, not on every PR,
|
||||
# since they require downloading the Stalwart binary via Nix.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: DeterminateSystems/nix-installer-action@v14
|
||||
|
||||
- uses: DeterminateSystems/magic-nix-cache-action@v8
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: "3.41.6"
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Enter Nix shell and run integration tests
|
||||
run: |
|
||||
nix develop --command bash -c "
|
||||
flutter pub get &&
|
||||
dart run build_runner build --delete-conflicting-outputs &&
|
||||
stalwart-dev/test.sh
|
||||
"
|
||||
|
||||
build-linux:
|
||||
name: Build Linux desktop
|
||||
runs-on: ubuntu-latest
|
||||
needs: analyze-and-test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install GTK3 and build tools
|
||||
run: |
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev pkg-config cmake ninja-build clang
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: "3.41.6"
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate Drift code
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Build Linux release
|
||||
run: flutter build linux --release
|
||||
+11
@@ -15,12 +15,23 @@ build/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local environment overrides (copied from .env.example)
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/local.properties
|
||||
android/app/google-services.json
|
||||
android/key.properties
|
||||
|
||||
# iOS / macOS
|
||||
ios/Pods/
|
||||
macos/Pods/
|
||||
*.xcworkspace/
|
||||
|
||||
# Linux build output
|
||||
linux/build/
|
||||
|
||||
# Stalwart dev server runtime data (created by stalwart-dev/start)
|
||||
/tmp/stalwart-dev-*/
|
||||
|
||||
@@ -2,44 +2,137 @@
|
||||
|
||||
IMAP/SMTP email client written in [Flutter](https://flutter.dev).
|
||||
|
||||
Targets **Android, iOS, and Desktop** (Linux, macOS, Windows).
|
||||
Targets **Android, iOS, and Desktop** (Linux done; macOS, Windows, Android, iOS scaffolded).
|
||||
Supports **multiple accounts** — each synced independently via IMAP IDLE.
|
||||
|
||||
## Design philosophy: offline-first
|
||||
|
||||
```
|
||||
```text
|
||||
IMAP/SMTP server
|
||||
↓
|
||||
AccountSyncManager ←→ Drift (SQLite, local DB)
|
||||
↓
|
||||
(IMAP IDLE per account) ↓
|
||||
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.
|
||||
The UI never touches the network. The sync engine runs in the background and writes to a local [Drift](https://drift.simonbinder.eu/) database. Screens observe reactive streams from that DB.
|
||||
|
||||
## Platform support
|
||||
|
||||
| Platform | Status |
|
||||
| --- | --- |
|
||||
| Linux desktop | Working |
|
||||
| macOS desktop | Entry point pending |
|
||||
| Windows desktop | Entry point pending |
|
||||
| Android | Entry point pending |
|
||||
| iOS | Entry point pending |
|
||||
|
||||
## Key packages
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| `enough_mail` (vendored in `packages/`) | IMAP / SMTP / MIME |
|
||||
| `drift` | Local SQLite ORM |
|
||||
| `drift` | Local SQLite ORM (offline-first store) |
|
||||
| `flutter_riverpod` | State management / DI |
|
||||
| `go_router` | Navigation |
|
||||
| `flutter_secure_storage` | Password storage |
|
||||
|
||||
## Building
|
||||
`enough_mail` is vendored under `packages/` so it can be patched without waiting for an upstream release.
|
||||
|
||||
---
|
||||
|
||||
## For users
|
||||
|
||||
Download the latest release from the [Releases page](https://github.com/guettli/sharedinbox3/releases) *(not yet published)*.
|
||||
|
||||
Run the app, tap **+**, and enter your IMAP/SMTP server details. The app syncs your INBOX in the background using IMAP IDLE and works offline — the network is only needed during initial sync and when sending mail.
|
||||
|
||||
---
|
||||
|
||||
## For developers
|
||||
|
||||
### Prerequisites
|
||||
|
||||
[Nix](https://nixos.org/download) with flakes enabled, and [direnv](https://direnv.net/).
|
||||
|
||||
```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
|
||||
# One-time: allow direnv in this directory
|
||||
direnv allow
|
||||
```
|
||||
|
||||
## Vendored enough_mail
|
||||
`direnv` loads the Nix flake automatically — no manual `nix develop` needed after that. The flake pins **Flutter 3.41.6**, Android SDK, Stalwart mail server (for integration tests), and all Linux desktop build tools (GTK3, clang, cmake).
|
||||
|
||||
`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`.
|
||||
### First-time setup
|
||||
|
||||
```bash
|
||||
# Generate the Drift database layer (required before first build)
|
||||
task codegen
|
||||
|
||||
# Verify everything compiles and tests pass
|
||||
task check
|
||||
```
|
||||
|
||||
### Daily workflow
|
||||
|
||||
```bash
|
||||
task analyze # flutter analyze (uses analysis_options.yaml)
|
||||
task test # pure-Dart unit tests — fast, no device
|
||||
task test-flutter # Flutter widget tests
|
||||
task integration # IMAP/SMTP integration tests via local Stalwart server
|
||||
task run # flutter run -d linux
|
||||
task analyze-fix # dart fix --apply
|
||||
```
|
||||
|
||||
`task check` runs `analyze` + `test` in parallel — use it before every commit.
|
||||
|
||||
### After changing the DB schema
|
||||
|
||||
Edit `lib/data/db/database.dart`, then:
|
||||
|
||||
```bash
|
||||
task codegen # regenerates lib/data/db/database.g.dart
|
||||
```
|
||||
|
||||
`database.g.dart` is git-ignored; every developer must regenerate it after cloning or pulling schema changes.
|
||||
|
||||
### Integration tests
|
||||
|
||||
```bash
|
||||
task integration
|
||||
```
|
||||
|
||||
Starts a local [Stalwart](https://stalw.art) mail server on random ports, runs the tests in `test/integration/`, then stops it. No manual setup needed — Stalwart is provided by the Nix flake.
|
||||
|
||||
### Adding a screen
|
||||
|
||||
1. Create `lib/ui/screens/my_screen.dart` — extend `ConsumerWidget`.
|
||||
2. Add a `GoRoute` in `lib/ui/router.dart`.
|
||||
3. Read from Riverpod providers in `lib/di.dart`; never call the network directly from UI.
|
||||
|
||||
### Project layout
|
||||
|
||||
```text
|
||||
lib/
|
||||
core/
|
||||
models/ — plain Dart data classes (Account, Email, Mailbox, …)
|
||||
repositories/ — abstract interfaces
|
||||
sync/ — AccountSyncManager (IMAP IDLE + backoff)
|
||||
utils/ — htmlToPlain, fmtSize (pure functions, unit-tested)
|
||||
data/
|
||||
db/ — Drift schema + generated code
|
||||
imap/ — connectImap / connectSmtp helpers
|
||||
repositories/ — concrete implementations
|
||||
ui/
|
||||
screens/ — one file per screen
|
||||
router.dart — go_router route tree
|
||||
di.dart — Riverpod providers
|
||||
main.dart — entry point
|
||||
|
||||
packages/
|
||||
enough_mail/ — vendored IMAP/SMTP library (editable)
|
||||
|
||||
stalwart-dev/ — local mail server config + start/test scripts
|
||||
test/
|
||||
unit/ — pure-Dart unit tests (no device)
|
||||
integration/ — IMAP/SMTP tests against local Stalwart
|
||||
```
|
||||
|
||||
+17
-7
@@ -2,7 +2,7 @@ version: "3"
|
||||
|
||||
tasks:
|
||||
default:
|
||||
desc: Run all checks (analyze + test, in parallel)
|
||||
desc: Run all checks (analyze + unit tests, in parallel)
|
||||
deps: [check]
|
||||
|
||||
_nix-check:
|
||||
@@ -13,7 +13,7 @@ tasks:
|
||||
msg: "Not in nix dev shell. Run: nix develop"
|
||||
|
||||
codegen:
|
||||
desc: Run build_runner to generate Drift and Riverpod code
|
||||
desc: Generate Drift DB code (run after any schema change)
|
||||
deps: [_nix-check]
|
||||
cmds:
|
||||
- |
|
||||
@@ -23,7 +23,7 @@ tasks:
|
||||
echo "codegen: $((END - START))s"
|
||||
|
||||
analyze:
|
||||
desc: Run flutter analyze
|
||||
desc: Static analysis with flutter analyze + analysis_options.yaml rules
|
||||
deps: [_nix-check]
|
||||
cmds:
|
||||
- |
|
||||
@@ -33,20 +33,30 @@ tasks:
|
||||
echo "analyze: $((END - START))s"
|
||||
|
||||
analyze-fix:
|
||||
desc: Auto-fix with dart fix --apply
|
||||
desc: Auto-fix lint issues with dart fix --apply
|
||||
deps: [_nix-check]
|
||||
cmds:
|
||||
- dart fix --apply
|
||||
|
||||
test:
|
||||
desc: Run unit tests
|
||||
desc: Run pure-Dart unit tests (no device needed)
|
||||
deps: [_nix-check]
|
||||
cmds:
|
||||
- |
|
||||
START=$(date +%s)
|
||||
dart test test/unit/
|
||||
END=$(date +%s)
|
||||
echo "test: $((END - START))s"
|
||||
|
||||
test-flutter:
|
||||
desc: Run Flutter widget tests
|
||||
deps: [_nix-check]
|
||||
cmds:
|
||||
- |
|
||||
START=$(date +%s)
|
||||
flutter test
|
||||
END=$(date +%s)
|
||||
echo "test: $((END - START))s"
|
||||
echo "flutter test: $((END - START))s"
|
||||
|
||||
integration:
|
||||
desc: Run integration tests (starts and stops Stalwart automatically)
|
||||
@@ -65,5 +75,5 @@ tasks:
|
||||
- flutter run -d linux
|
||||
|
||||
check:
|
||||
desc: All checks — analyze + unit tests in parallel
|
||||
desc: All fast checks — analyze + unit tests in parallel
|
||||
deps: [analyze, test]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
errors:
|
||||
# Treat these as errors, not warnings.
|
||||
unused_import: error
|
||||
unused_local_variable: error
|
||||
dead_code: error
|
||||
exclude:
|
||||
- lib/data/db/database.g.dart
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
|
||||
linter:
|
||||
rules:
|
||||
# Style
|
||||
- prefer_single_quotes
|
||||
- prefer_const_constructors
|
||||
- prefer_const_declarations
|
||||
- prefer_const_collections
|
||||
- prefer_final_locals
|
||||
- prefer_final_in_for_each
|
||||
- unnecessary_const
|
||||
- unnecessary_new
|
||||
- unnecessary_this
|
||||
- avoid_redundant_argument_values
|
||||
|
||||
# Safety
|
||||
- avoid_catching_errors
|
||||
- avoid_dynamic_calls
|
||||
- avoid_empty_else
|
||||
- avoid_print
|
||||
- avoid_returning_null_for_future
|
||||
- avoid_returning_null_for_void
|
||||
- avoid_type_to_string
|
||||
- cancel_subscriptions
|
||||
- close_sinks
|
||||
- literal_only_boolean_expressions
|
||||
- no_duplicate_case_values
|
||||
- throw_in_finally
|
||||
- unawaited_futures
|
||||
|
||||
# Correctness
|
||||
- always_declare_return_types
|
||||
- annotate_overrides
|
||||
- empty_catches # flag silent catch{} — use comment if intentional
|
||||
- hash_and_equals
|
||||
- use_rethrow_when_possible
|
||||
- valid_regexps
|
||||
@@ -6,6 +6,7 @@ import '../models/account.dart';
|
||||
import '../repositories/account_repository.dart';
|
||||
import '../repositories/email_repository.dart';
|
||||
import '../repositories/mailbox_repository.dart';
|
||||
import '../utils/logger.dart';
|
||||
import '../../data/imap/imap_client_factory.dart';
|
||||
|
||||
/// Manages one IMAP IDLE connection per account.
|
||||
@@ -80,7 +81,12 @@ class _AccountSync {
|
||||
await _sync();
|
||||
await _idle();
|
||||
_backoffSeconds = 5;
|
||||
} catch (_) {
|
||||
} catch (e, st) {
|
||||
log(
|
||||
'Sync failed for ${account.email}, retrying in ${_backoffSeconds}s',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
await Future.delayed(Duration(seconds: _backoffSeconds));
|
||||
_backoffSeconds = (_backoffSeconds * 2).clamp(5, 300);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/// Returns a human-readable file size string (B / KB / MB / GB).
|
||||
String fmtSize(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/// Strips HTML tags and decodes common entities to produce plain text.
|
||||
///
|
||||
/// Not a full HTML parser — handles the common subset found in email bodies.
|
||||
String htmlToPlain(String html) => html
|
||||
.replaceAll(RegExp(r'<br\s*/?>'), '\n')
|
||||
.replaceAll(RegExp(r'<p\s*/?>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'</p>', caseSensitive: false), '')
|
||||
.replaceAll(RegExp(r'<[^>]+>'), '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(' ', ' ')
|
||||
.trim();
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Thin wrapper around [debugPrint] so log calls are easy to find and
|
||||
/// can be swapped for a structured logger later without touching callers.
|
||||
void log(String message, {Object? error, StackTrace? stackTrace}) {
|
||||
if (error != null) {
|
||||
debugPrint('[SharedInbox] $message — $error');
|
||||
if (stackTrace != null) debugPrint(stackTrace.toString());
|
||||
} else {
|
||||
debugPrint('[SharedInbox] $message');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:enough_mail/enough_mail.dart';
|
||||
|
||||
import '../../core/models/account.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
|
||||
/// Opens an authenticated IMAP client for [account].
|
||||
Future<ImapClient> connectImap(Account account, String password) async {
|
||||
@@ -35,8 +36,8 @@ Future<SmtpClient> connectSmtp(Account account, String password) async {
|
||||
// Opportunistic TLS on submission port (587)
|
||||
try {
|
||||
await client.startTls();
|
||||
} catch (_) {
|
||||
// Server doesn't support STARTTLS — proceed without it.
|
||||
} catch (e) {
|
||||
log('STARTTLS not available on ${account.smtpHost}: $e — continuing without TLS');
|
||||
}
|
||||
}
|
||||
await client.authenticate(account.email, password);
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 '../../core/utils/logger.dart';
|
||||
import '../imap/imap_client_factory.dart';
|
||||
|
||||
class MailboxRepositoryImpl implements MailboxRepository {
|
||||
@@ -46,7 +47,10 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
);
|
||||
unread = status.messagesUnseen;
|
||||
total = status.messagesExists;
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
// \Noselect mailboxes can't be STATUSed — skip counts silently.
|
||||
log('STATUS skipped for $path: $e');
|
||||
}
|
||||
|
||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../core/models/email.dart';
|
||||
import '../../core/utils/format_utils.dart';
|
||||
import '../../core/utils/html_utils.dart';
|
||||
import '../../di.dart';
|
||||
|
||||
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
|
||||
@@ -86,7 +88,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
const Divider(),
|
||||
],
|
||||
SelectableText(
|
||||
body.textBody ?? _htmlToPlain(body.htmlBody ?? ''),
|
||||
body.textBody ?? htmlToPlain(body.htmlBody ?? ''),
|
||||
style: Theme.of(ctx).textTheme.bodyMedium,
|
||||
),
|
||||
if (body.attachments.isNotEmpty) ...[
|
||||
@@ -103,7 +105,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
dense: true,
|
||||
leading: const Icon(Icons.attach_file),
|
||||
title: Text(att.filename),
|
||||
subtitle: Text(_fmtSize(att.size)),
|
||||
subtitle: Text(fmtSize(att.size)),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -154,18 +156,4 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
String _htmlToPlain(String html) => 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';
|
||||
}
|
||||
}
|
||||
|
||||
+2
-8
@@ -23,7 +23,6 @@ dependencies:
|
||||
|
||||
# State management
|
||||
flutter_riverpod: ^2.6.1
|
||||
riverpod_annotation: ^2.4.1
|
||||
|
||||
# Navigation
|
||||
go_router: ^14.8.1
|
||||
@@ -31,21 +30,16 @@ dependencies:
|
||||
# Secure credential storage (passwords)
|
||||
flutter_secure_storage: ^9.2.4
|
||||
|
||||
# Utilities
|
||||
freezed_annotation: ^2.4.4
|
||||
json_annotation: ^4.9.0
|
||||
# Date formatting
|
||||
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
|
||||
test: ^1.25.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Tests for the exponential-backoff clamping logic used in AccountSyncManager.
|
||||
// The manager itself requires Flutter DI, so we test the math standalone.
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
int nextBackoff(int current) => (current * 2).clamp(5, 300);
|
||||
|
||||
void main() {
|
||||
group('sync backoff', () {
|
||||
test('starts at 5 seconds', () {
|
||||
expect(5, 5);
|
||||
});
|
||||
|
||||
test('doubles each failure', () {
|
||||
var backoff = 5;
|
||||
backoff = nextBackoff(backoff);
|
||||
expect(backoff, 10);
|
||||
backoff = nextBackoff(backoff);
|
||||
expect(backoff, 20);
|
||||
backoff = nextBackoff(backoff);
|
||||
expect(backoff, 40);
|
||||
backoff = nextBackoff(backoff);
|
||||
expect(backoff, 80);
|
||||
backoff = nextBackoff(backoff);
|
||||
expect(backoff, 160);
|
||||
});
|
||||
|
||||
test('clamps at 300 seconds (5 minutes)', () {
|
||||
var backoff = 5;
|
||||
for (var i = 0; i < 20; i++) {
|
||||
backoff = nextBackoff(backoff);
|
||||
}
|
||||
expect(backoff, 300);
|
||||
});
|
||||
|
||||
test('never goes below 5 seconds', () {
|
||||
expect(nextBackoff(1), 5);
|
||||
expect(nextBackoff(0), 5);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
|
||||
// Mirrors the encoding logic in EmailRepositoryImpl so we can test it
|
||||
// independently without spinning up a database.
|
||||
String encodeAddresses(List<EmailAddress> addresses) => jsonEncode(
|
||||
addresses
|
||||
.map((a) => {'name': a.name, 'email': a.email})
|
||||
.toList(),
|
||||
);
|
||||
|
||||
List<EmailAddress> decodeAddresses(String json) {
|
||||
final list = jsonDecode(json) as List;
|
||||
return list
|
||||
.map(
|
||||
(e) => EmailAddress(
|
||||
name: e['name'] as String?,
|
||||
email: e['email'] as String,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('EmailAddress JSON roundtrip', () {
|
||||
test('encodes and decodes a single address with name', () {
|
||||
const addr = EmailAddress(name: 'Alice', email: 'alice@example.com');
|
||||
final decoded = decodeAddresses(encodeAddresses([addr]));
|
||||
expect(decoded, hasLength(1));
|
||||
expect(decoded.first.name, 'Alice');
|
||||
expect(decoded.first.email, 'alice@example.com');
|
||||
});
|
||||
|
||||
test('encodes and decodes an address without a display name', () {
|
||||
const addr = EmailAddress(email: 'bob@example.com');
|
||||
final decoded = decodeAddresses(encodeAddresses([addr]));
|
||||
expect(decoded.first.name, isNull);
|
||||
expect(decoded.first.email, 'bob@example.com');
|
||||
});
|
||||
|
||||
test('encodes and decodes multiple addresses', () {
|
||||
final addresses = [
|
||||
const EmailAddress(name: 'Alice', email: 'alice@example.com'),
|
||||
const EmailAddress(email: 'bob@example.com'),
|
||||
];
|
||||
final decoded = decodeAddresses(encodeAddresses(addresses));
|
||||
expect(decoded, hasLength(2));
|
||||
expect(decoded[0].email, 'alice@example.com');
|
||||
expect(decoded[1].email, 'bob@example.com');
|
||||
});
|
||||
|
||||
test('encodes empty list', () {
|
||||
final decoded = decodeAddresses(encodeAddresses([]));
|
||||
expect(decoded, isEmpty);
|
||||
});
|
||||
|
||||
test('handles special characters in display name', () {
|
||||
const addr = EmailAddress(name: 'Müller, Hans', email: 'hans@example.de');
|
||||
final decoded = decodeAddresses(encodeAddresses([addr]));
|
||||
expect(decoded.first.name, 'Müller, Hans');
|
||||
});
|
||||
});
|
||||
|
||||
group('EmailAddress.toString', () {
|
||||
test('includes name when present', () {
|
||||
const addr = EmailAddress(name: 'Alice', email: 'alice@example.com');
|
||||
expect(addr.toString(), 'Alice <alice@example.com>');
|
||||
});
|
||||
|
||||
test('returns just email when name is null', () {
|
||||
const addr = EmailAddress(email: 'alice@example.com');
|
||||
expect(addr.toString(), 'alice@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
group('EmailDraft', () {
|
||||
test('constructs with required fields', () {
|
||||
final draft = EmailDraft(
|
||||
from: const EmailAddress(name: 'Me', email: 'me@example.com'),
|
||||
to: [const EmailAddress(email: 'you@example.com')],
|
||||
cc: [],
|
||||
subject: 'Hello',
|
||||
body: 'World',
|
||||
);
|
||||
expect(draft.subject, 'Hello');
|
||||
expect(draft.to, hasLength(1));
|
||||
expect(draft.cc, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('fmtSize', () {
|
||||
test('formats bytes', () {
|
||||
expect(fmtSize(0), '0 B');
|
||||
expect(fmtSize(1), '1 B');
|
||||
expect(fmtSize(1023), '1023 B');
|
||||
});
|
||||
|
||||
test('formats kilobytes', () {
|
||||
expect(fmtSize(1024), '1.0 KB');
|
||||
expect(fmtSize(1536), '1.5 KB');
|
||||
expect(fmtSize(1024 * 1023), '1023.0 KB');
|
||||
});
|
||||
|
||||
test('formats megabytes', () {
|
||||
expect(fmtSize(1024 * 1024), '1.0 MB');
|
||||
expect(fmtSize((1024 * 1024 * 2.5).round()), '2.5 MB');
|
||||
});
|
||||
|
||||
test('formats gigabytes', () {
|
||||
expect(fmtSize(1024 * 1024 * 1024), '1.0 GB');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('htmlToPlain', () {
|
||||
test('returns plain text unchanged', () {
|
||||
expect(htmlToPlain('Hello world'), 'Hello world');
|
||||
});
|
||||
|
||||
test('strips simple tags', () {
|
||||
expect(htmlToPlain('<b>bold</b> text'), 'bold text');
|
||||
});
|
||||
|
||||
test('converts <br> to newline', () {
|
||||
expect(htmlToPlain('line1<br>line2'), 'line1\nline2');
|
||||
});
|
||||
|
||||
test('converts self-closing <br/> to newline', () {
|
||||
expect(htmlToPlain('line1<br/>line2'), 'line1\nline2');
|
||||
});
|
||||
|
||||
test('converts <p> to newline', () {
|
||||
expect(htmlToPlain('<p>paragraph</p>'), '\nparagraph');
|
||||
});
|
||||
|
||||
test('decodes &', () {
|
||||
expect(htmlToPlain('a & b'), 'a & b');
|
||||
});
|
||||
|
||||
test('decodes < and >', () {
|
||||
expect(htmlToPlain('<tag>'), '<tag>');
|
||||
});
|
||||
|
||||
test('decodes "', () {
|
||||
expect(htmlToPlain('say "hi"'), 'say "hi"');
|
||||
});
|
||||
|
||||
test('decodes '', () {
|
||||
expect(htmlToPlain('it's'), "it's");
|
||||
});
|
||||
|
||||
test('decodes as space', () {
|
||||
expect(htmlToPlain('a b'), 'a b');
|
||||
});
|
||||
|
||||
test('trims surrounding whitespace', () {
|
||||
expect(htmlToPlain(' hello '), 'hello');
|
||||
});
|
||||
|
||||
test('handles empty string', () {
|
||||
expect(htmlToPlain(''), '');
|
||||
});
|
||||
|
||||
test('handles nested tags', () {
|
||||
expect(htmlToPlain('<div><p>text</p></div>'), '\ntext');
|
||||
});
|
||||
|
||||
test('real-world HTML email snippet', () {
|
||||
const html = '<p>Hello <b>Alice</b>,</p>'
|
||||
'<p>Please find the invoice attached.</p>'
|
||||
'<p>Best regards,<br/>Bob</p>';
|
||||
final result = htmlToPlain(html);
|
||||
expect(result, contains('Hello Alice,'));
|
||||
expect(result, contains('Best regards,'));
|
||||
expect(result, contains('Bob'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user