Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 ef6583de18 feat: add combined inbox as the default startup view
Creates a Combined Inbox that shows threads from the INBOX mailbox of
every account, sorted by date. On first launch the app opens here; if
no accounts exist yet it redirects to the account list automatically.

- EmailRepository: add observeAllInboxThreads() — JOINs threads with
  mailboxes on role='inbox', covering both IMAP and JMAP accounts
- di.dart: add allAccountsProvider (StreamProvider for all accounts)
- CombinedInboxScreen: thread list with swipe-to-archive/delete, pull-
  to-refresh, load-more pagination, per-account name label, compose FAB,
  and a drawer linking to individual account mailboxes
- router.dart: add /inbox route; change initialLocation to /inbox
- Update all test fakes and mocks for the new abstract method

Closes #376

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 02:34:17 +02:00
82 changed files with 1203 additions and 3679 deletions
-1
View File
@@ -4,7 +4,6 @@ jobs:
check:
name: Full Project Check
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Setup Dagger Remote Engine
-1
View File
@@ -10,7 +10,6 @@ jobs:
# Disabled until a self-hosted runner with label "windows-runner" is registered.
name: Build & Deploy Windows (Nightly)
runs-on: windows-runner
timeout-minutes: 90
if: false
steps:
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.44.0"
"flutter": "3.44.1"
}
+250
View File
@@ -0,0 +1,250 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
analyze-and-test:
name: Analyze & unit test
runs-on: sharedinbox-runner
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: flutter pub run build_runner build --delete-conflicting-outputs
- name: Check formatting
run: dart format --set-exit-if-changed .
- name: Analyze
run: flutter analyze --fatal-infos
- name: Unit + widget tests with coverage
run: flutter test test/unit/ test/widget/ --coverage
- name: Coverage gate
run: dart run scripts/check_coverage.dart
integration:
name: Integration tests (Stalwart)
runs-on: sharedinbox-runner
# Run integration tests only on push to main, not on every PR.
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
- name: Cache FVM Flutter SDK
uses: actions/cache@v4
with:
path: ~/.fvm
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: pub-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-
- name: Run integration tests
run: |
nix develop --command bash -c "
fvm install --skip-pub-get &&
fvm flutter pub get &&
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
stalwart-dev/test.sh
"
integration-ui:
name: UI Integration tests (Stalwart + Xvfb)
runs-on: sharedinbox-runner
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
- name: Install Flutter Linux build dependencies
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev pkg-config cmake ninja-build clang \
libsecret-1-dev
- name: Cache FVM Flutter SDK
uses: actions/cache@v4
with:
path: ~/.fvm
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: pub-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-
- name: Cache Linux debug build
uses: actions/cache@v4
with:
path: |
build/linux
.dart_tool/flutter_build
key: linux-debug-${{ hashFiles('pubspec.lock', 'lib/**/*.dart', 'integration_test/**/*.dart') }}
restore-keys: linux-debug-
- name: Run UI integration tests
run: |
nix develop --command bash -c "
fvm install --skip-pub-get &&
fvm flutter pub get &&
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
stalwart-dev/integration_ui_test.sh
"
build-linux:
name: Build Linux desktop
runs-on: sharedinbox-runner
needs: analyze-and-test
steps:
- uses: actions/checkout@v4
- name: Install GTK3, build tools and libsecret
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev pkg-config cmake ninja-build clang \
libsecret-1-dev
- 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: flutter pub run build_runner build --delete-conflicting-outputs
- name: Build Linux release
run: flutter build linux --release
deploy:
name: Deploy Linux build & publish website
runs-on: sharedinbox-runner
needs: build-linux
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
steps:
- uses: actions/checkout@v4
- name: Install build & deploy dependencies
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev pkg-config cmake ninja-build clang \
libsecret-1-dev hugo rsync
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.41.6"
channel: stable
cache: true
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: pub-${{ hashFiles('pubspec.lock') }}
restore-keys: pub-
- name: Install dependencies
run: flutter pub get
- name: Generate Drift code
run: flutter pub run build_runner build --delete-conflicting-outputs
- name: Generate changelog
run: |
mkdir -p assets
git log -n 50 \
--pretty=format:'* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s' \
--date=short > assets/changelog.txt
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Build Linux release
run: |
HASH=$(git rev-parse --short HEAD)
flutter build linux --release --no-pub --dart-define=GIT_HASH=$HASH
- name: Deploy Linux build to server
run: |
HASH=$(git rev-parse --short HEAD)
DATE_PATH=$(date -u +%Y/%m/%d)
REMOTE_DIR="public_html/builds/$DATE_PATH"
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" \
"cat public_html/latest.json 2>/dev/null || echo '{}'")
WINDOWS_URL=$(echo "$EXISTING" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
2>/dev/null || true)
if [ -n "$WINDOWS_URL" ]; then
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
else
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
fi
- name: Generate build history pages
run: python3 scripts/generate_build_history.py
- name: Build website
env:
HUGO_PARAMS_GITVERSION: ${{ github.sha }}
run: hugo --source website --minify
- name: Deploy website
run: |
rsync -avz --delete \
--exclude='*.apk' \
--exclude='*.tar.gz' \
website/public/ \
"$SSH_USER@$SSH_HOST:public_html/"
-1
View File
@@ -1,6 +1,5 @@
# --- Flutter/Dart ---
coverage/
screenshots/
.dart_tool/
.dart-tool/
.packages
+1 -12
View File
@@ -10,11 +10,6 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/guettli/sync-branch
rev: v0.0.11
hooks:
- id: sync-branch
- repo: local
hooks:
- id: check-no-binary
@@ -32,7 +27,7 @@ repos:
- id: dart-check
name: dart format (autofix) + check-fast (parallel)
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
pass_filenames: false
always_run: true
- id: ci-no-direct-dagger
@@ -47,9 +42,3 @@ repos:
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
pass_filenames: false
always_run: true
- id: ci-image-exists
name: verify container images in ci/main.go are reachable
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
pass_filenames: false
files: ^(ci/main\.go|\.fvmrc)$
+59
View File
@@ -0,0 +1,59 @@
# Implementation Plan: Secure WebView for HTML Emails (#21)
## Goal
Replace the current `flutter_html` based rendering with a hardened WebView-based approach to improve rendering fidelity while strictly enforcing security and privacy.
## 1. Dependency Management
- **Core**: `webview_flutter` (v4+)
- **Linux Platform**: `webview_flutter_linux` (Official community-supported or WebKitGTK based implementation). *Note: I will verify the exact package name during implementation.*
- **Utilities**: `url_launcher` (existing) for opening links in the system browser.
## 2. Secure WebView Component (`lib/ui/widgets/secure_email_webview.dart`)
Create a new widget `SecureEmailWebView` that encapsulates the `WebViewWidget` and its controller.
### Configuration & Hardening
- **Disable JavaScript**: `controller.setJavaScriptMode(JavaScriptMode.disabled)`.
- **Background**: Match the application theme (e.g., transparent or surface color).
- **Security Headers/CSP**: Inject a Content Security Policy via `<meta>` tag in the HTML wrapper:
- `default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:;` (Blocks all external assets by default).
### Image Blocking Logic
- **Initial State**: Block remote images by injecting a CSP that restricts `img-src` to `data:` and local schemes.
- **Toggle Mechanism**:
- Provide a "Load Remote Images" button in the Flutter UI.
- When triggered, re-render the HTML with an updated CSP: `img-src * data:;`.
### Link Interception & Phishing Protection
- Implement `NavigationDelegate.onNavigationRequest`.
- **Process**:
1. Intercept any URL that doesn't start with `about:blank` or `data:`.
2. Block the navigation in the WebView.
3. Trigger a Flutter `showDialog` for confirmation.
- **Phishing Protection Dialog**:
- Show the full URL.
- **Bold the FQDN**: Parse the URL using `Uri.parse`.
- Example: `https://`**`important-bank.com`**`/login`
- "Open in Browser" button uses `url_launcher`.
## 3. Integration Plan
### Step 1: Initialization
Modify `lib/main.dart` to initialize the Linux WebView platform (using `webview_flutter_linux` or similar) during app startup.
### Step 2: Replace Renderer in Screens
- **EmailDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
- **ThreadDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
- Remove `flutter_html` imports and dependencies once migration is complete.
## 4. Verification & Security Audit
- **Manual Tests**:
- Open emails with complex HTML layouts.
- Verify images are blocked initially.
- Verify "Load images" works.
- Click various links (http, https, mailto) and verify the confirmation dialog and FQDN bolding.
- **Security Check**:
- Verify that `<script>` tags are not executed.
- Verify no network requests for external images occur before user consent (via DevTools or proxy).
## 5. Potential Challenges
- **Linux WebView Stability**: WebKitGTK on Linux can sometimes have rendering or sizing issues in Flutter.
- **Scrolling**: Ensuring the WebView integrates smoothly into the `ListView` of the email detail screen (might require fixed height or `SizedBox`).
+46
View File
@@ -0,0 +1,46 @@
# Snooze Feature Plan
## Goal
Allow users to snooze emails, moving them to a special folder and bringing them back to the Inbox at a specified time. Snooze data must be stored in the account (IMAP/JMAP) for cross-device synchronization.
## Technical Approach
### 1. Metadata Storage (Account Sync)
- **Keyword format:** `snz:<ISO8601_TIMESTAMP>` (e.g., `snz:2026-05-10T15:00:00Z`).
- **JMAP:** Use `keywords`.
- **IMAP:** Use User Flags (keywords).
### 2. Database Changes
- **Migration v22:**
- `Emails` table:
- `snoozedUntil` (DateTime, nullable)
- `snoozedFromMailboxPath` (String, nullable) - to remember where to move it back (usually INBOX).
- Index on `snoozedUntil`.
### 3. Repository Updates (`EmailRepository`)
- New method: `Future<void> snoozeEmail(String emailId, DateTime until)`
- Optimistically update local DB.
- Enqueue `snooze` change.
- New method: `Future<int> wakeUpEmails(String accountId)`
- Find local rows where `snoozedUntil <= now`.
- Enqueue `move` back to original mailbox.
- Clear snooze metadata.
### 4. Sync Loop Integration
- In `AccountSyncManager`, call `wakeUpEmails(accountId)` at the start of each sync cycle.
- Update IMAP/JMAP sync logic to parse `snz:` keywords and update local `snoozedUntil` / `snoozedFromMailboxPath`.
### 5. UI Implementation
- **Snooze Picker:** A dialog with options like "Later today", "Tomorrow morning", "Next week", "Custom".
- **Action:** Add "Snooze" icon to `EmailListScreen` selection bar and `EmailDetailScreen`.
- **Mailbox:** Ensure a "Snoozed" mailbox exists (create if missing).
## Implementation Steps
1. [ ] Database migration and model updates.
2. [ ] Repository implementation for `snoozeEmail` and `wakeUpEmails`.
3. [ ] Update flush logic for IMAP and JMAP to handle `snooze` mutations.
4. [ ] Update sync logic to parse snooze keywords.
5. [ ] Integrate `wakeUpEmails` into the sync loop.
6. [ ] UI: Snooze picker dialog.
7. [ ] UI: Add Snooze action to list and detail screens.
8. [ ] Testing and validation.
+5
View File
@@ -216,3 +216,8 @@ test/
- **Settings** — list and remove accounts
- **Search** — IMAP server-side search (subject + body); results shown inline, no navigation change
- **Offline-first** — all reads come from local Drift/SQLite DB; network only for sync and send
# CI Trigger
# CI Trigger 2
# Dummy commit to verify CI fixes
# Dummy commit 3
# CI Trigger 1780415300
+46 -56
View File
@@ -37,8 +37,6 @@ tasks:
run: once
deps: [_nix-check]
preconditions:
- sh: '[ "$(id -u)" != "0" ]'
msg: "Do not run as root. Use the dedicated dev user (see DEVELOPMENT.md)."
- sh: test -n "${IN_NIX_SHELL}"
msg: "Not in nix dev shell. Run: nix develop"
cmds:
@@ -98,19 +96,34 @@ tasks:
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
codegen:
desc: Generate Drift DB code via Dagger (exports generated files back to host)
desc: Generate Drift DB code (run after any schema change)
deps: [_preflight, _pub-get]
sources:
- lib/**/*.dart
- pubspec.yaml
generates:
- lib/**/*.g.dart
cmds:
- dagger call --progress=plain -q -m ci --source=. codegen -o .
- fvm flutter pub run build_runner build --delete-conflicting-outputs
analyze:
desc: Static analysis via Dagger (dart analyze --fatal-infos)
desc: Static analysis (flutter analyze)
deps: [_preflight, _codegen]
sources:
- lib/**/*.dart
- test/**/*.dart
- pubspec.yaml
- analysis_options.yaml
cmds:
- dagger call --progress=plain -q -m ci --source=. analyze
- scripts/run_analyze.sh
format:
desc: Format all Dart source files via Dagger (writes back to host)
desc: Format all Dart source files
deps: [_preflight]
sources:
- "**/*.dart"
cmds:
- dagger call --progress=plain -q -m ci --source=. format-write -o .
- fvm dart format lib test
check-mocks:
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
@@ -123,9 +136,13 @@ tasks:
- scripts/check_mocks_fresh.sh
analyze-fix:
desc: Auto-fix lint issues via Dagger (dart fix --apply, writes back to host)
desc: Auto-fix lint issues with dart fix --apply
deps: [_preflight]
sources:
- lib/**/*.dart
- test/**/*.dart
cmds:
- dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
- fvm dart fix --apply
test:
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
@@ -160,17 +177,17 @@ tasks:
test-backend:
desc: Backend tests against a local Stalwart mail server (via Dagger)
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-backend
- dagger call --progress=plain -q -m ci --source=. test-backend
integration-ui:
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-integration
- dagger call --progress=plain -q -m ci --source=. test-integration
sync-reliability:
desc: Run sync reliability runner (via Dagger)
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-sync-reliability
- dagger call --progress=plain -q -m ci --source=. test-sync-reliability
test-android-firebase:
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
@@ -185,7 +202,7 @@ tasks:
ci-graph:
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
cmds:
- timeout --kill-after=10 60 dagger call --progress=plain -q -m ci --source=. graph
- dagger call --progress=plain -q -m ci --source=. graph
stalwart:
desc: Start a Stalwart instance for local development (via Dagger)
@@ -201,13 +218,13 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
build-android-bundle:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
cmds:
- mkdir -p build/app/outputs/bundle/release
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
upload-android-bundle:
desc: Upload AAB from build/ to Play Store via Dagger
@@ -217,7 +234,7 @@ tasks:
- sh: test -f build/app/outputs/bundle/release/app-release.aab
msg: "AAB not found — run build-android-bundle first"
cmds:
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
- dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
publish-android:
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
@@ -230,7 +247,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
deploy-apk:
desc: Build and deploy Android APK via Dagger
@@ -244,7 +261,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
publish-website:
desc: Build and publish website via Dagger
@@ -254,7 +271,7 @@ tasks:
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
check-dagger:
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
@@ -334,7 +351,7 @@ tasks:
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
msg: "RENOVATE_FORGEJO_TOKEN is not set"
cmds:
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
integration-android:
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
@@ -409,22 +426,6 @@ tasks:
fi
echo "Uploaded $TARBALL and updated latest.json"
deploy-bugreport:
desc: Deploy the Go bugreport server by restarting the systemd service (it pulls latest code from Codeberg)
preconditions:
- sh: test -n "$SSH_USER"
msg: "SSH_USER is not set"
- sh: test -n "$SSH_HOST"
msg: "SSH_HOST is not set"
- sh: test -n "$SSH_KNOWN_HOSTS"
msg: "SSH_KNOWN_HOSTS is not set"
cmds:
- |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
ssh "root@$SSH_HOST" "systemctl restart bugreport"
echo "Restarted bugreport service on $SSH_HOST to pull latest code from Codeberg"
build-windows-release:
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
deps: [_pub-get, generate-changelog]
@@ -524,14 +525,15 @@ tasks:
deploy-android-bundle:
desc: Build release AAB and upload to Play Store internal track (local/fvm)
deps: [build-android-bundle-local]
dotenv: [".env"]
preconditions:
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
msg: "PLAY_STORE_CONFIG_JSON is not set"
cmds:
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
- python3 scripts/deploy_playstore.py
build-android-bundle-local:
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
dotenv: [".env"]
method: timestamp
sources:
- lib/**/*.dart
@@ -540,7 +542,7 @@ tasks:
generates:
- build/app/outputs/bundle/release/app-release.aab
cmds:
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --build-name $(date +%y%m%d-%H%M) --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
deploy-android:
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
@@ -567,7 +569,7 @@ tasks:
run:
desc: Run the app on Linux desktop
deps: [_preflight, _linux-deps-check, _pub-get, _codegen]
deps: [_preflight, _linux-deps-check, _pub-get]
cmds:
- fvm flutter run -d linux --no-pub
@@ -670,9 +672,8 @@ tasks:
${SSH_USER}@${SSH_HOST}:public_html/
check-fast:
desc: Pre-commit checks via Dagger (format, analyze, mocks, coverage — no integration or backend)
cmds:
- dagger call --progress=plain -q -m ci --source=. check-fast
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
check-layers:
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
@@ -699,11 +700,6 @@ tasks:
fi
echo "Hygiene check passed."
check-ci-images:
desc: Verify that all container images referenced in ci/main.go are reachable
cmds:
- scripts/check_ci_images.sh
_integrations:
internal: true
run: once
@@ -716,12 +712,6 @@ tasks:
cmds:
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
screenshots:
desc: Generate Play Store promotional screenshots (30 golden files — 3 devices × 2 themes × 5 scenes)
deps: [_preflight, _codegen]
cmds:
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
check:
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
deps: [analyze, build-linux, test]
+16 -13
View File
@@ -22,17 +22,15 @@ android {
}
}
val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
if (ksPath != null) {
signingConfigs {
create("release") {
keyAlias = "upload"
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
storePassword = pass
keyPassword = pass
storeFile = file(ksPath)
}
signingConfigs {
create("release") {
// Hardcoded alias matching t.sh
keyAlias = "upload"
// Use the same password for both key and keystore
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD")
storePassword = pass
keyPassword = pass
storeFile = file("upload-keystore.jks")
}
}
@@ -48,9 +46,14 @@ android {
buildTypes {
release {
if (ksPath != null) {
signingConfig = signingConfigs.getByName("release")
// Use the signing config defined above for release builds.
// If the keystore file exists (e.g. in CI or manually placed), sign it.
signingConfig = if (signingConfigs.getByName("release").storeFile?.exists() == true) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
isMinifyEnabled = false
isShrinkResources = false
ndk {
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
+1 -1
View File
@@ -20,7 +20,7 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
}
include(":app")
+41 -1
View File
@@ -2,4 +2,44 @@ module dagger/ci
go 1.26.2
require golang.org/x/sync v0.20.0
require (
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72
github.com/Khan/genqlient v0.8.1
github.com/dagger/otel-go v1.43.0
github.com/vektah/gqlparser/v2 v2.5.33
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
)
require (
github.com/99designs/gqlgen v0.17.90 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/sosodev/duration v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
go.opentelemetry.io/otel/log v0.20.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+127
View File
@@ -1,2 +1,129 @@
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 h1:s39e07WvaUU6tLhpojK8ZEIoIbOSn5hHOJra0waenxQ=
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dagger/otel-go v1.43.0 h1:AYCnAamWmxtSxigWPTgC+8EWqiWPcDZEegh8y05gdJ8=
github.com/dagger/otel-go v1.43.0/go.mod h1:83CTuXi70zcx1kaym5buqmb7RNzg1E9dEiQSFyLbLdU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+22 -108
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"dagger/ci/internal/dagger"
"encoding/json"
"fmt"
"time"
@@ -149,33 +148,16 @@ if __name__ == "__main__":
`
type Ci struct {
Source *dagger.Directory
FlutterVersion string
Source *dagger.Directory
}
func New(
ctx context.Context,
// +defaultPath=".."
source *dagger.Directory,
) (*Ci, error) {
fvmrcContents, err := source.File(".fvmrc").Contents(ctx)
if err != nil {
return nil, fmt.Errorf("failed to read .fvmrc: %w", err)
}
var fvmrc struct {
Flutter string `json:"flutter"`
}
if err := json.Unmarshal([]byte(fvmrcContents), &fvmrc); err != nil {
return nil, fmt.Errorf("failed to parse .fvmrc: %w", err)
}
if fvmrc.Flutter == "" {
return nil, fmt.Errorf(".fvmrc is missing the 'flutter' field")
}
) *Ci {
return &Ci{
FlutterVersion: fvmrc.Flutter,
Source: source.Filter(dagger.DirectoryFilterOpts{
Include: []string{
".fvmrc",
"lib/",
"test/",
"assets/",
@@ -191,7 +173,7 @@ func New(
"website/",
},
}),
}, nil
}
}
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
@@ -199,7 +181,7 @@ func New(
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
func (m *Ci) toolchain() *dagger.Container {
return dag.Container().
From("ghcr.io/cirruslabs/flutter:"+m.FlutterVersion).
From("ghcr.io/cirruslabs/flutter:3.44.0").
WithExec([]string{"apt-get", "-qq", "update"}).
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
@@ -356,17 +338,12 @@ func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.
return dag.Container().
From("alpine:3.21").
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
// Create .ssh with strict permissions before Dagger mounts anything there,
// so the directory is 700 (not Dagger's default 755).
WithExec([]string{"sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"}).
// Mount the raw key outside .ssh so Dagger cannot override the directory
// permissions we just set above.
WithMountedSecret("/tmp/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
// Normalise with Python3: strip CRLF/bare-CR, ensure trailing newline.
// Using Python3 (not tr) changes the Dagger cache key so stale cached
// results from the old tr-based step are not reused.
WithExec([]string{"python3", "-c",
"import os; raw=open('/tmp/id_ed25519.raw','rb').read(); key=raw.replace(b'\\r\\n',b'\\n').replace(b'\\r',b'\\n'); key=key if key.endswith(b'\\n') else key+b'\\n'; open('/root/.ssh/id_ed25519','wb').write(key); os.chmod('/root/.ssh/id_ed25519',0o600)"}).
// Mount at a raw path so we can normalise before use: strip any CRLF line
// endings that appear when the key is stored or exported on Windows, which
// cause "error in libcrypto" in Alpine's LibreSSL-backed openssh.
WithMountedSecret("/root/.ssh/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
WithExec([]string{"sh", "-c",
"tr -d '\\r' < /root/.ssh/id_ed25519.raw > /root/.ssh/id_ed25519 && chmod 600 /root/.ssh/id_ed25519"}).
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
}
@@ -440,73 +417,11 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
Stdout(ctx)
}
// FormatWrite formats Dart files and exports the modified /src directory.
func (m *Ci) FormatWrite() *dagger.Directory {
return m.setup(m.checkSrc()).
WithExec([]string{"dart", "format", "lib", "test"}).
Directory("/src")
}
// Analyze runs static analysis with dart analyze --fatal-infos.
func (m *Ci) Analyze(ctx context.Context) (string, error) {
return m.setup(m.checkSrc()).
WithExec([]string{"dart", "analyze", "--fatal-infos"}).
Stdout(ctx)
}
// Codegen runs build_runner and exports the modified /src directory.
func (m *Ci) Codegen() *dagger.Directory {
return m.codegenBase().Directory("/src")
}
// AnalyzeFix runs dart fix --apply and exports the modified /src directory.
func (m *Ci) AnalyzeFix() *dagger.Directory {
return m.setup(m.checkSrc()).
WithExec([]string{"dart", "fix", "--apply"}).
Directory("/src")
}
// CheckFast runs fast checks (hygiene, layers, format, analyze, mocks, coverage) in parallel.
func (m *Ci) CheckFast(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
var eg errgroup.Group
eg.Go(func() error {
_, err := m.CheckHygiene(ctx)
return err
})
eg.Go(func() error {
_, err := m.CheckLayers(ctx)
return err
})
eg.Go(func() error {
_, err := m.Format(ctx)
return err
})
eg.Go(func() error {
_, err := m.Analyze(ctx)
return err
})
eg.Go(func() error {
_, err := m.CheckGenerated(ctx)
return err
})
eg.Go(func() error {
_, err := m.Coverage(ctx)
return err
})
if err := eg.Wait(); err != nil {
return "", err
}
return "All fast checks passed!", nil
}
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
// It snapshots the committed source (including any stale generated files) before
// CheckMocks verifies that generated mocks are up to date.
// It snapshots the committed source (including any stale *.mocks.dart) before
// running build_runner, so git diff detects real staleness instead of always
// comparing two freshly-generated outputs.
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
return m.pubGetLayer().
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
WithWorkdir("/src").
@@ -519,16 +434,16 @@ func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . \\( -name '*.g.dart' -o -name '*.mocks.dart' \\) | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Generated files are out of date — run: dart run build_runner build\"; exit 1; fi; echo \"Generated files are up to date.\""}).
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
Stdout(ctx)
}
// Coverage runs unit and widget tests with coverage gate.
// Coverage runs unit tests with coverage gate.
func (m *Ci) Coverage(ctx context.Context) (string, error) {
return m.setup(m.checkSrc()).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test test/unit test/widget --exclude-tags golden --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`flutter test test/unit --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
Stdout(ctx)
@@ -595,7 +510,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
return analyze, err
}
mocks, err := m.CheckGenerated(ctx)
mocks, err := m.CheckMocks(ctx)
if err != nil {
return mocks, err
}
@@ -749,8 +664,7 @@ func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagg
return m.androidBase().
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/upload-keystore.jks`}).
WithEnvVariable("ANDROID_KEYSTORE_PATH", "/tmp/upload-keystore.jks")
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
}
// BuildAndroidApk builds a release APK signed with the upload key.
@@ -983,12 +897,12 @@ func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string
//
// dagger call --progress=plain -q -m ci --source=. graph
func (m *Ci) Graph() string {
return fmt.Sprintf(`# CI Pipeline Graph
return `# CI Pipeline Graph
`+"```"+`mermaid
` + "```" + `mermaid
flowchart TD
subgraph dagger ["Dagger · Check pipeline"]
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
pubGet["pubGetLayer\nflutter pub get"]
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
@@ -998,7 +912,7 @@ flowchart TD
pubGet --> hygiene["CheckHygiene"]
pubGet --> layers["CheckLayers"]
pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
codegen --> fmt["Format"]
codegen --> analyze["Analyze"]
-1
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# Load .env into environment
-1
View File
@@ -99,7 +99,6 @@
httplib2
])) # used by stalwart-dev/start and deploy_playstore.py
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
]);
shellHook = ''
-3
View File
@@ -1,3 +0,0 @@
module codeberg.org/guettli/sharedinbox
go 1.22
-25
View File
@@ -1,25 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" shape-rendering="geometricPrecision">
<!-- White Background -->
<rect width="512" height="512" fill="white"/>
<!-- 6 Concentric Rainbow Rings (Tunnel Vision Geometry) -->
<g fill-rule="evenodd" stroke="black" stroke-width="2.5">
<!-- Red -->
<path fill="#FF0000" d="M256,256 m-242,0 a242,242 0 1,0 484,0 a242,242 0 1,0 -484,0 Z M256,256 m-190,0 a190,190 0 1,0 380,0 a190,190 0 1,0 -380,0 Z" />
<!-- Orange -->
<path fill="#FF8C00" d="M256,256 m-170,0 a170,170 0 1,0 340,0 a170,170 0 1,0 -340,0 Z M256,256 m-131,0 a131,131 0 1,0 262,0 a131,131 0 1,0 -262,0 Z" />
<!-- Yellow -->
<path fill="#FFD700" d="M256,256 m-115,0 a115,115 0 1,0 230,0 a115,115 0 1,0 -230,0 Z M256,256 m-85,0 a85,85 0 1,0 170,0 a85,85 0 1,0 -170,0 Z" />
<!-- Green -->
<path fill="#22AA00" d="M256,256 m-73,0 a73,73 0 1,0 146,0 a73,73 0 1,0 -146,0 Z M256,256 m-51,0 a51,51 0 1,0 102,0 a51,51 0 1,0 -102,0 Z" />
<!-- Blue -->
<path fill="#0055FF" d="M256,256 m-41,0 a41,41 0 1,0 82,0 a41,41 0 1,0 -82,0 Z M256,256 m-24,0 a24,24 0 1,0 48,0 a24,24 0 1,0 -48,0 Z" />
<!-- Purple -->
<path fill="#8B00FF" d="M256,256 m-16,0 a16,16 0 1,0 32,0 a16,16 0 1,0 -32,0 Z M256,256 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0 Z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 39;
const int dbSchemaVersion = 37;
-16
View File
@@ -192,22 +192,6 @@ class EmailThread {
required this.accountId,
required this.mailboxPath,
});
/// Wraps a single [Email] as a one-message thread for uniform rendering.
factory EmailThread.fromEmail(Email e) => EmailThread(
threadId: e.threadId ?? e.id,
subject: e.subject,
participants: e.from,
latestDate: e.sentAt ?? e.receivedAt,
messageCount: 1,
hasUnread: !e.isSeen,
isFlagged: e.isFlagged,
latestEmailId: e.id,
preview: e.preview,
emailIds: [e.id],
accountId: e.accountId,
mailboxPath: e.mailboxPath,
);
}
class EmailAddress {
-17
View File
@@ -1,17 +0,0 @@
class EmailNote {
final String id; // UUID (X-SharedInbox-Note-Id)
final String accountId;
final String messageId; // RFC 2822 Message-ID (X-SharedInbox-Note-For)
final String noteText;
final String serverId; // IMAP UID (as string) or JMAP email ID
final DateTime createdAt;
const EmailNote({
required this.id,
required this.accountId,
required this.messageId,
required this.noteText,
required this.serverId,
required this.createdAt,
});
}
-17
View File
@@ -2,30 +2,13 @@ enum MenuPosition { bottom, top }
enum AfterMailViewAction { nextMessage, showMailbox }
enum PrefetchMode {
disabled,
wifiOnly,
always;
static PrefetchMode fromString(String? value) {
return PrefetchMode.values.firstWhere(
(e) => e.name == value,
orElse: () => PrefetchMode.wifiOnly,
);
}
}
class UserPreferences {
const UserPreferences({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
this.prefetchMode = PrefetchMode.wifiOnly,
this.bodyCacheLimitMb = 100,
});
final MenuPosition menuPosition;
final MenuPosition mailViewButtonPosition;
final AfterMailViewAction afterMailViewAction;
final PrefetchMode prefetchMode;
final int bodyCacheLimitMb;
}
+2 -2
View File
@@ -15,8 +15,8 @@ abstract class EmailRepository {
int limit = 50,
});
/// Returns threads from the INBOX mailbox of every account, sorted by latest
/// message date descending. Inbox mailboxes are identified by role = 'inbox'.
/// Returns threads from the INBOX of every account, sorted by latest date.
/// Inbox mailboxes are matched by role = 'inbox'.
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50});
/// Returns all emails belonging to [threadId] in [mailboxPath].
@@ -20,8 +20,4 @@ abstract class MailboxRepository {
String name,
String role,
);
/// Creates a new mailbox named [name] for [accountId] without a special role.
/// Returns the newly created [Mailbox].
Future<Mailbox> createMailbox(String accountId, String name);
}
@@ -1,15 +0,0 @@
import 'package:sharedinbox/core/models/note.dart';
abstract class NoteRepository {
/// Stream of notes for an email, keyed by [messageId] (stable across moves).
Stream<List<EmailNote>> observeNotes(String accountId, String messageId);
/// Fetches notes from the server into the local cache.
Future<void> syncNotes(String accountId, String messageId);
/// Creates a new note on the server and caches it locally.
Future<void> addNote(String accountId, String messageId, String text);
/// Deletes a note from the server and removes it from the local cache.
Future<void> deleteNote(String noteId);
}
@@ -5,8 +5,6 @@ abstract class UserPreferencesRepository {
Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
Future<void> updatePrefetchMode(PrefetchMode mode);
Future<void> updateBodyCacheLimitMb(int mb);
Stream<List<String>> observeTrustedImageSenders();
Future<void> addTrustedImageSender(String senderEmail);
-82
View File
@@ -1,82 +0,0 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
/// Prefetches email bodies in the background and enforces a local cache size
/// limit by evicting the oldest cached bodies when the limit is exceeded.
class BodyCacheService {
BodyCacheService(this._db, this._accountRepo);
final AppDatabase _db;
final AccountRepository _accountRepo;
static const _batchSize = 20;
Future<void> run() async {
final prefs = await (_db.select(
_db.userPreferences,
)).getSingleOrNull();
final limitMb = prefs?.bodyCacheLimitMb ?? 100;
final limitBytes = limitMb * 1024 * 1024;
await _evictIfNeeded(limitBytes);
final candidates = await _fetchCandidates();
if (candidates.isEmpty) return;
final emailRepo = EmailRepositoryImpl(_db, _accountRepo);
for (final emailId in candidates) {
final currentSize = await _getCacheSizeBytes();
if (currentSize >= limitBytes) break;
try {
await emailRepo.getEmailBody(emailId);
} catch (_) {
// Skip emails that fail to fetch.
}
}
}
Future<void> _evictIfNeeded(int limitBytes) async {
final currentSize = await _getCacheSizeBytes();
if (currentSize <= limitBytes) return;
final bodies = await (_db.select(_db.emailBodies)
..where((t) => t.cachedAt.isNotNull())
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
.get();
var remaining = currentSize;
for (final body in bodies) {
if (remaining <= limitBytes) break;
final bodySize =
(body.textBody?.length ?? 0) + (body.htmlBody?.length ?? 0);
await (_db.delete(_db.emailBodies)
..where((t) => t.emailId.equals(body.emailId)))
.go();
remaining -= bodySize;
}
}
Future<int> _getCacheSizeBytes() async {
final result = await _db
.customSelect(
"SELECT COALESCE(SUM(LENGTH(COALESCE(text_body, '')) + LENGTH(COALESCE(html_body, ''))), 0) AS total FROM email_bodies",
)
.getSingle();
return result.read<int>('total');
}
Future<List<String>> _fetchCandidates() async {
final rows = await _db.customSelect(
'SELECT e.id FROM emails e '
'LEFT JOIN email_bodies eb ON eb.email_id = e.id '
'WHERE eb.email_id IS NULL '
'ORDER BY e.received_at DESC '
'LIMIT ?',
variables: [Variable.withInt(_batchSize)],
).get();
return rows.map((r) => r.read<String>('id')).toList();
}
}
+2 -50
View File
@@ -11,9 +11,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/services/body_cache_service.dart';
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
@@ -23,7 +21,6 @@ import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
import 'package:workmanager/workmanager.dart';
const _kTaskName = 'si_bg_sync';
const _kPrefetchTaskName = 'si_bg_prefetch';
const _kResourceType = 'background_check';
@pragma('vm:entry-point')
@@ -31,13 +28,9 @@ void callbackDispatcher() {
// Required so that path_provider and other plugins are available in this
// background isolate (issue #192).
WidgetsFlutterBinding.ensureInitialized();
Workmanager().executeTask((taskName, __) async {
Workmanager().executeTask((_, __) async {
try {
if (taskName == _kPrefetchTaskName) {
await _doBodyPrefetch();
} else {
await _doBackgroundSync();
}
await _doBackgroundSync();
} catch (_) {}
return true;
});
@@ -62,31 +55,6 @@ Future<void> registerBackgroundSync() async {
}
}
/// Registers (or cancels) the body-prefetch WorkManager task based on [mode].
/// Call on app startup and whenever the user changes the prefetch preference.
Future<void> registerBodyPrefetchTask(PrefetchMode mode) async {
try {
if (mode == PrefetchMode.disabled) {
await Workmanager().cancelByUniqueName(_kPrefetchTaskName);
return;
}
final networkType = mode == PrefetchMode.wifiOnly
? NetworkType.unmetered
: NetworkType.connected;
await Workmanager().registerPeriodicTask(
_kPrefetchTaskName,
_kPrefetchTaskName,
frequency: const Duration(hours: 1),
constraints: Constraints(networkType: networkType),
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
);
} on PlatformException {
// Ignore — WorkManager unavailable.
} on MissingPluginException {
// Ignore — plugin not registered.
} catch (_) {}
}
Future<void> _doBackgroundSync() async {
final dir = await getApplicationSupportDirectory();
final db = AppDatabase(
@@ -108,22 +76,6 @@ Future<void> _doBackgroundSync() async {
}
}
Future<void> _doBodyPrefetch() async {
final dir = await getApplicationSupportDirectory();
final db = AppDatabase(
NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))),
);
try {
final accountRepo = AccountRepositoryImpl(
db,
const FlutterSecureStorageImpl(),
);
await BodyCacheService(db, accountRepo).run();
} finally {
await db.close();
}
}
Future<void> _checkAccount(
AppDatabase db,
AccountRepository accountRepo,
-37
View File
@@ -318,26 +318,6 @@ class ImageTrustedSenders extends Table {
Set<Column> get primaryKey => {senderEmail};
}
/// Per-email notes stored server-side (IMAP Notes folder / JMAP Notes mailbox).
/// Keyed by the RFC 2822 Message-ID header so notes survive folder moves.
// Added in schema v39.
@DataClassName('EmailNoteRow')
class EmailNotes extends Table {
// UUID matching the X-SharedInbox-Note-Id custom header on the server.
TextColumn get id => text()();
TextColumn get accountId =>
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
// X-SharedInbox-Note-For value — stable across IMAP folder moves.
TextColumn get messageId => text()();
TextColumn get noteText => text()();
// IMAP UID (as string) or JMAP email ID of the note message on the server.
TextColumn get serverId => text()();
DateTimeColumn get createdAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
/// App-wide user preferences, stored as a singleton row (id always 1).
@DataClassName('UserPreferencesRow')
class UserPreferences extends Table {
@@ -350,12 +330,6 @@ class UserPreferences extends Table {
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
TextColumn get afterMailViewAction =>
text().withDefault(const Constant('nextMessage'))();
// Added in schema v38: 'disabled' | 'wifiOnly' (default) | 'always'
TextColumn get prefetchMode =>
text().withDefault(const Constant('wifiOnly'))();
// Added in schema v38: max cache size for offline email bodies, in megabytes.
IntColumn get bodyCacheLimitMb =>
integer().withDefault(const Constant(100))();
@override
Set<Column> get primaryKey => {id};
@@ -383,7 +357,6 @@ class UserPreferences extends Table {
ShareKeys,
UserPreferences,
ImageTrustedSenders,
EmailNotes,
],
)
class AppDatabase extends _$AppDatabase {
@@ -653,16 +626,6 @@ class AppDatabase extends _$AppDatabase {
if (from < 37) {
await m.createTable(imageTrustedSenders);
}
if (from >= 34 && from < 38) {
await m.addColumn(userPreferences, userPreferences.prefetchMode);
await m.addColumn(
userPreferences,
userPreferences.bodyCacheLimitMb,
);
}
if (from < 39) {
await m.createTable(emailNotes);
}
},
);
}
@@ -343,23 +343,11 @@ class MailboxRepositoryImpl implements MailboxRepository {
}
}
@override
Future<model.Mailbox> createMailbox(String accountId, String name) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
return _createMailboxWithRoleImap(account, password, name, null);
case account_model.AccountType.jmap:
return _createMailboxWithRoleJmap(account, password, name, null);
}
}
Future<model.Mailbox> _createMailboxWithRoleImap(
account_model.Account account,
String password,
String name,
String? role,
String role,
) async {
final client = await _imapConnect(
account,
@@ -392,7 +380,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
account_model.Account account,
String password,
String name,
String? role,
String role,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
@@ -410,10 +398,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
{
'accountId': jmap.accountId,
'create': {
'new-mailbox': {
'name': name,
if (role != null) 'role': role,
},
'new-mailbox': {'name': name, 'role': role},
},
},
'0',
@@ -1,570 +0,0 @@
import 'dart:math' as math;
import 'package:drift/drift.dart';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as account_model;
import 'package:sharedinbox/core/models/note.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/note_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/jmap_client.dart';
const _notesFolder = 'Notes';
const _headerNoteFor = 'X-SharedInbox-Note-For';
const _headerNoteId = 'X-SharedInbox-Note-Id';
class NoteRepositoryImpl implements NoteRepository {
NoteRepositoryImpl(
this._db,
this._accounts, {
ImapConnectFn imapConnect = connectImap,
http.Client? httpClient,
}) : _imapConnect = imapConnect,
_httpClient = httpClient ?? http.Client();
final AppDatabase _db;
final AccountRepository _accounts;
final ImapConnectFn _imapConnect;
final http.Client _httpClient;
String _effectiveUsername(account_model.Account account) =>
account.username.isNotEmpty ? account.username : account.email;
// ── Observe (local cache) ─────────────────────────────────────────────────
@override
Stream<List<EmailNote>> observeNotes(String accountId, String messageId) {
return (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(accountId) & t.messageId.equals(messageId),
)
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.watch()
.map((rows) => rows.map(_toModel).toList());
}
// ── Sync (server → local cache) ──────────────────────────────────────────
@override
Future<void> syncNotes(String accountId, String messageId) async {
final account = await _accounts.getAccount(accountId);
if (account == null) return;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
await _syncNotesImap(account, password, messageId);
case account_model.AccountType.jmap:
await _syncNotesJmap(account, password, messageId);
}
}
Future<void> _syncNotesImap(
account_model.Account account,
String password,
String messageId,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.selectMailboxByPath(_notesFolder);
} catch (_) {
// Notes folder doesn't exist — nothing to sync.
return;
}
final escaped = messageId.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
final searchResult = await client.uidSearchMessages(
searchCriteria: 'HEADER $_headerNoteFor "$escaped"',
);
final uids = searchResult.matchingSequence?.toList() ?? [];
if (uids.isEmpty) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final seq = imap.MessageSequence.fromIds(uids, isUid: true);
final fetch = await client.uidFetchMessages(seq, '(UID BODY.PEEK[])');
final fetchedIds = <String>{};
for (final msg in fetch.messages) {
final uid = msg.uid;
if (uid == null) continue;
final noteId = msg.getHeaderValue(_headerNoteId)?.trim();
if (noteId == null || noteId.isEmpty) continue;
fetchedIds.add(noteId);
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: msg.decodeTextPlainPart() ?? '',
serverId: uid.toString(),
createdAt: msg.decodeDate() ?? DateTime.now(),
),
);
}
// Remove stale local notes (deleted on the server).
final local = await (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.get();
for (final note in local) {
if (!fetchedIds.contains(note.id)) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
.go();
}
}
} finally {
await client.logout();
}
}
Future<void> _syncNotesJmap(
account_model.Account account,
String password,
String messageId,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) return;
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final mailboxId = await _findNotesMailboxJmap(jmap);
if (mailboxId == null) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final queryResp = await jmap.call([
[
'Email/query',
{
'accountId': jmap.accountId,
'filter': {'inMailbox': mailboxId},
},
'0',
],
]);
final ids = List<String>.from(
(_responseArgs(queryResp, 0, 'Email/query')['ids'] as List? ?? []),
);
if (ids.isEmpty) {
await (_db.delete(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
))
.go();
return;
}
final getResp = await jmap.call([
[
'Email/get',
{
'accountId': jmap.accountId,
'ids': ids,
'properties': [
'id',
'receivedAt',
'textBody',
'bodyValues',
'header:$_headerNoteFor:asText',
'header:$_headerNoteId:asText',
],
'fetchTextBodyValues': true,
},
'0',
],
]);
final list =
_responseArgs(getResp, 0, 'Email/get')['list'] as List<dynamic>;
final fetchedIds = <String>{};
for (final e in list) {
final m = e as Map<String, dynamic>;
final noteFor = (m['header:$_headerNoteFor:asText'] as String?)?.trim();
if (noteFor != messageId) continue;
final noteId = (m['header:$_headerNoteId:asText'] as String?)?.trim();
if (noteId == null || noteId.isEmpty) continue;
final jmapEmailId = m['id'] as String;
final bodyValues = m['bodyValues'] as Map<String, dynamic>? ?? {};
final textBodyParts = m['textBody'] as List<dynamic>? ?? [];
var noteText = '';
if (textBodyParts.isNotEmpty) {
final partId =
(textBodyParts.first as Map<String, dynamic>)['partId'] as String?;
if (partId != null) {
noteText = (bodyValues[partId] as Map<String, dynamic>?)?['value']
as String? ??
'';
}
}
final createdAt =
DateTime.tryParse(m['receivedAt'] as String? ?? '') ?? DateTime.now();
fetchedIds.add(noteId);
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: noteText,
serverId: jmapEmailId,
createdAt: createdAt,
),
);
}
// Remove stale local notes.
final local = await (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) & t.messageId.equals(messageId),
))
.get();
for (final note in local) {
if (!fetchedIds.contains(note.id)) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
.go();
}
}
}
// ── Add ───────────────────────────────────────────────────────────────────
@override
Future<void> addNote(
String accountId,
String messageId,
String text,
) async {
final account = await _accounts.getAccount(accountId);
if (account == null) return;
final password = await _accounts.getPassword(accountId);
final noteId = _generateId();
switch (account.type) {
case account_model.AccountType.imap:
await _addNoteImap(account, password, messageId, noteId, text);
case account_model.AccountType.jmap:
await _addNoteJmap(account, password, messageId, noteId, text);
}
}
Future<void> _addNoteImap(
account_model.Account account,
String password,
String messageId,
String noteId,
String text,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.createMailbox(_notesFolder);
} catch (_) {
// Already exists.
}
final builder = imap.MessageBuilder()
..subject = 'Note'
..text = text;
builder.addHeader(_headerNoteFor, messageId);
builder.addHeader(_headerNoteId, noteId);
final mime = builder.buildMimeMessage();
final appendResult = await client.appendMessage(
mime,
targetMailboxPath: _notesFolder,
);
final uidList =
appendResult.responseCodeAppendUid?.targetSequence.toList();
final serverId = (uidList != null && uidList.isNotEmpty)
? uidList.first.toString()
: '';
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: text,
serverId: serverId,
createdAt: DateTime.now(),
),
);
} finally {
await client.logout();
}
}
Future<void> _addNoteJmap(
account_model.Account account,
String password,
String messageId,
String noteId,
String text,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('JMAP account ${account.id} has no jmapUrl');
}
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final mailboxId = await _findOrCreateNotesMailboxJmap(jmap);
const bodyPartId = '1';
final setResp = await jmap.call([
[
'Email/set',
{
'accountId': jmap.accountId,
'create': {
'new-note': {
'mailboxIds': {mailboxId: true},
'subject': 'Note',
'keywords': {r'$seen': true},
'headers': [
{'name': _headerNoteFor, 'value': ' $messageId'},
{'name': _headerNoteId, 'value': ' $noteId'},
],
'bodyValues': {
bodyPartId: {
'value': text,
'isEncodingProblem': false,
'isTruncated': false,
},
},
'textBody': [
{'partId': bodyPartId, 'type': 'text/plain'},
],
},
},
},
'0',
],
]);
final result = _responseArgs(setResp, 0, 'Email/set');
final created = result['created'] as Map<String, dynamic>?;
final newEmail = created?['new-note'] as Map<String, dynamic>?;
final jmapEmailId = newEmail?['id'] as String? ?? '';
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
id: noteId,
accountId: account.id,
messageId: messageId,
noteText: text,
serverId: jmapEmailId,
createdAt: DateTime.now(),
),
);
}
// ── Delete ────────────────────────────────────────────────────────────────
@override
Future<void> deleteNote(String noteId) async {
final noteRow = await (_db.select(_db.emailNotes)
..where((t) => t.id.equals(noteId)))
.getSingleOrNull();
if (noteRow == null) return;
final account = await _accounts.getAccount(noteRow.accountId);
if (account == null) {
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteId)))
.go();
return;
}
final password = await _accounts.getPassword(account.id);
switch (account.type) {
case account_model.AccountType.imap:
await _deleteNoteImap(account, password, noteRow);
case account_model.AccountType.jmap:
await _deleteNoteJmap(account, password, noteRow);
}
}
Future<void> _deleteNoteImap(
account_model.Account account,
String password,
EmailNoteRow noteRow,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
try {
await client.selectMailboxByPath(_notesFolder);
final uid = int.tryParse(noteRow.serverId);
if (uid != null) {
final seq = imap.MessageSequence.fromId(uid, isUid: true);
await client.uidMarkDeleted(seq);
await client.uidExpunge(seq);
}
} catch (_) {
// Notes folder gone or message already deleted — clean up locally.
}
} finally {
await client.logout();
}
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
.go();
}
Future<void> _deleteNoteJmap(
account_model.Account account,
String password,
EmailNoteRow noteRow,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) return;
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
if (noteRow.serverId.isNotEmpty) {
await jmap.call([
[
'Email/set',
{
'accountId': jmap.accountId,
'destroy': [noteRow.serverId],
},
'0',
],
]);
}
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
.go();
}
// ── JMAP helpers ──────────────────────────────────────────────────────────
Future<String?> _findNotesMailboxJmap(JmapClient jmap) async {
final resp = await jmap.call([
[
'Mailbox/get',
{'accountId': jmap.accountId, 'ids': null},
'0',
],
]);
final list = _responseArgs(resp, 0, 'Mailbox/get')['list'] as List<dynamic>;
for (final m in list) {
final map = m as Map<String, dynamic>;
if (map['name'] == _notesFolder) return map['id'] as String?;
}
return null;
}
Future<String> _findOrCreateNotesMailboxJmap(JmapClient jmap) async {
final existing = await _findNotesMailboxJmap(jmap);
if (existing != null) return existing;
final resp = await jmap.call([
[
'Mailbox/set',
{
'accountId': jmap.accountId,
'create': {
'new-notes': {'name': _notesFolder},
},
},
'0',
],
]);
final result = _responseArgs(resp, 0, 'Mailbox/set');
final created = result['created'] as Map<String, dynamic>?;
final newMailbox = created?['new-notes'] as Map<String, dynamic>?;
return newMailbox?['id'] as String? ?? _notesFolder;
}
Map<String, dynamic> _responseArgs(
List<dynamic> responses,
int index,
String expectedMethod,
) {
final triple = responses[index] as List<dynamic>;
final method = triple[0] as String;
if (method == 'error') {
final err = triple[1] as Map<String, dynamic>;
throw JmapException('$expectedMethod error: ${err['type']}');
}
return triple[1] as Map<String, dynamic>;
}
EmailNote _toModel(EmailNoteRow row) => EmailNote(
id: row.id,
accountId: row.accountId,
messageId: row.messageId,
noteText: row.noteText,
serverId: row.serverId,
createdAt: row.createdAt,
);
// Generates a random UUID v4.
static String _generateId() {
final rng = math.Random.secure();
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}'
'-${hex.substring(12, 16)}-${hex.substring(16, 20)}'
'-${hex.substring(20)}';
}
}
@@ -50,26 +50,6 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
);
}
@override
Future<void> updatePrefetchMode(pref.PrefetchMode mode) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
prefetchMode: Value(mode.name),
),
);
}
@override
Future<void> updateBodyCacheLimitMb(int mb) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
bodyCacheLimitMb: Value(mb),
),
);
}
@override
Stream<List<String>> observeTrustedImageSenders() {
return (_db.select(_db.imageTrustedSenders)
@@ -110,8 +90,6 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
(e) => e.name == row.afterMailViewAction,
orElse: () => pref.AfterMailViewAction.nextMessage,
),
prefetchMode: pref.PrefetchMode.fromString(row.prefetchMode),
bodyCacheLimitMb: row.bodyCacheLimitMb,
);
}
}
-18
View File
@@ -4,14 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/note.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/note_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
@@ -34,7 +32,6 @@ import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'package:sharedinbox/data/repositories/note_repository_impl.dart';
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
@@ -285,18 +282,3 @@ final trustedImageSendersProvider =
.watch(userPreferencesRepositoryProvider)
.observeTrustedImageSenders();
});
final noteRepositoryProvider = Provider<NoteRepository>((ref) {
return NoteRepositoryImpl(
ref.watch(dbProvider),
ref.watch(accountRepositoryProvider),
imapConnect: ref.watch(imapConnectProvider),
);
});
/// Stream of notes for a specific email, identified by (accountId, messageId).
final notesProvider =
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
(ref, params) =>
ref.watch(noteRepositoryProvider).observeNotes(params.$1, params.$2),
);
+5 -32
View File
@@ -5,30 +5,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/services/notification_service.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
import 'package:sharedinbox/data/db/database.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/router.dart';
import 'package:sharedinbox/ui/screens/crash_screen.dart';
import 'package:stack_trace/stack_trace.dart' as stack_trace;
void main({List<Override> overrides = const []}) {
void main({List<Override> overrides = const []}) async {
unawaited(
runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized();
// Dart's async machinery propagates stack traces in chain format
// (with '===== asynchronous gap =====' separators). Flutter's
// StackFrame parser asserts on those lines, so strip them first.
FlutterError.demangleStackTrace = (StackTrace s) {
if (s is stack_trace.Chain) return s.toTrace().vmTrace;
if (s is stack_trace.Trace) return s.vmTrace;
return s;
};
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
ErrorWidget.builder = (details) => CrashScreen(
exception: details.exception,
@@ -50,35 +39,19 @@ void main({List<Override> overrides = const []}) {
if (Platform.isAndroid) {
await initNotifications();
await registerBackgroundSync();
await _registerPrefetchTaskFromStoredPrefs();
}
runApp(
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
);
},
// This handler runs in the parent zone — runApp cannot be called here.
// Framework errors are already handled by FlutterError.onError above.
(error, stack) => FlutterError.reportError(
FlutterErrorDetails(exception: error, stack: stack),
),
(error, stack) {
// Catch unhandled async errors.
runApp(CrashScreen(exception: error, stackTrace: stack));
},
),
);
}
/// Reads the stored prefetch preference and registers the WorkManager task
/// with the correct network constraint for it. Opens and immediately closes
/// a temporary DB connection; safe because initDatabasePath() has already run.
Future<void> _registerPrefetchTaskFromStoredPrefs() async {
final db = AppDatabase();
try {
final row = await db.select(db.userPreferences).getSingleOrNull();
final mode = PrefetchMode.fromString(row?.prefetchMode);
await registerBodyPrefetchTask(mode);
} finally {
await db.close();
}
}
class SharedInboxApp extends ConsumerStatefulWidget {
const SharedInboxApp({super.key});
+1 -15
View File
@@ -4,13 +4,12 @@ import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/ui/screens/about_screen.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
import 'package:sharedinbox/ui/screens/bug_report_screen.dart';
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
import 'package:sharedinbox/ui/screens/compose_screen.dart';
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
@@ -21,7 +20,6 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart';
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
@@ -68,12 +66,6 @@ final router = GoRouter(
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute(
path: 'trusted-senders',
builder: (ctx, state) => TrustedImageSendersScreen(
highlightedSender: state.extra as String?,
),
),
GoRoute(
path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen(
@@ -177,12 +169,6 @@ final router = GoRouter(
);
},
),
GoRoute(
path: '/bug-report',
builder: (ctx, state) => BugReportScreen(
emailId: state.uri.queryParameters['emailId'],
),
),
],
),
],
+5 -14
View File
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
@@ -198,30 +197,22 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.copy),
label: const Text('Copy info'),
label: const Text('Copy to clipboard'),
onPressed: () => unawaited(
_copyToClipboard(context, imapCount, jmapCount),
),
),
),
const SizedBox(width: 4),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.bug_report_outlined),
label: const Text('Public issue'),
child: FilledButton.icon(
icon: const Icon(Icons.bug_report),
label: const Text('Create issue'),
onPressed: () => unawaited(
_createIssue(context, imapCount, jmapCount),
),
),
),
const SizedBox(width: 4),
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.feedback_outlined),
label: const Text('Report bug'),
onPressed: () => context.push('/bug-report'),
),
),
],
),
),
-635
View File
@@ -1,635 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
const _bugReportApiUrl = String.fromEnvironment(
'BUG_REPORT_API_URL',
defaultValue: 'https://sharedinbox.de/api/v1/bug-reports',
);
class BugReportScreen extends ConsumerStatefulWidget {
const BugReportScreen({super.key, this.emailId});
final String? emailId;
@override
ConsumerState<BugReportScreen> createState() => _BugReportScreenState();
}
class _BugReportScreenState extends ConsumerState<BugReportScreen> {
final _formKey = GlobalKey<FormState>();
final _descriptionController = TextEditingController();
final _emailController = TextEditingController();
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Future<String?> _deviceModelFuture = getDeviceModel();
final List<PlatformFile> _attachments = [];
bool _includeEmail = false;
bool _includeSyncLog = false;
bool _submitting = false;
Email? _attachedEmail;
List<Account> _accounts = [];
String? _selectedAccountId;
String? _deviceModel;
bool _loadingEmail = false;
@override
void initState() {
super.initState();
unawaited(_loadInitialData());
}
@override
void dispose() {
_descriptionController.dispose();
_emailController.dispose();
super.dispose();
}
Future<void> _loadInitialData() async {
setState(() => _loadingEmail = true);
try {
_deviceModel = await _deviceModelFuture;
_accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
if (widget.emailId != null) {
final email =
await ref.read(emailRepositoryProvider).getEmail(widget.emailId!);
if (mounted && email != null) {
_attachedEmail = email;
_selectedAccountId = email.accountId;
final fromStr =
email.from.isNotEmpty ? email.from.first.toString() : 'unknown';
final subjectStr = email.subject ?? '(no subject)';
_descriptionController.text =
'Problem with email from $fromStr: "$subjectStr"\n\n';
}
}
if (_selectedAccountId == null && _accounts.isNotEmpty) {
_selectedAccountId = _accounts.first.id;
}
if (_selectedAccountId != null) {
final matching =
_accounts.where((a) => a.id == _selectedAccountId).firstOrNull;
if (matching != null) {
_emailController.text = matching.email;
}
}
} catch (_) {}
if (mounted) {
setState(() => _loadingEmail = false);
}
}
int get _totalAttachmentSize {
return _attachments.fold(0, (sum, f) => sum + f.size);
}
String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
Future<void> _pickAttachments() async {
try {
final result = await FilePicker.pickFiles();
if (result == null) return;
final newFiles =
result.files.where((PlatformFile f) => f.path != null).toList();
if (!mounted) return;
setState(() {
_attachments.addAll(newFiles);
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to pick files: $e')),
);
}
}
}
void _removeAttachment(int index) {
setState(() {
_attachments.removeAt(index);
});
}
String _serializeSyncLogs(List<SyncLogEntry> entries) {
final sb = StringBuffer();
for (final entry in entries.take(50)) {
sb.writeln('ID: ${entry.id}');
sb.writeln('Started: ${entry.startedAt.toIso8601String()}');
sb.writeln('Finished: ${entry.finishedAt.toIso8601String()}');
sb.writeln('Result: ${entry.result}');
if (entry.errorMessage != null) {
sb.writeln('Error: ${entry.errorMessage}');
}
if (entry.stackTrace != null) {
sb.writeln('StackTrace:\n${entry.stackTrace}');
}
sb.writeln('Protocol: ${entry.protocol}');
sb.writeln(
'Fetched: ${entry.emailsFetched}, Skipped: ${entry.emailsSkipped}',
);
if (entry.protocolLog != null) {
sb.writeln('Protocol Log:\n${entry.protocolLog}');
}
sb.writeln('---');
}
return sb.toString();
}
Future<void> _submitReport() async {
if (!_formKey.currentState!.validate()) return;
final totalSize = _totalAttachmentSize;
if (totalSize > 20 * 1024 * 1024) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Total attachments size exceeds the 20 MB limit. Please remove some files.',
),
backgroundColor: Colors.red,
),
);
return;
}
setState(() => _submitting = true);
try {
final client = ref.read(httpClientProvider);
final uri = Uri.parse(_bugReportApiUrl);
final request = http.MultipartRequest('POST', uri);
// Description
request.fields['description'] = _descriptionController.text;
// Email Data if from email view
if (_attachedEmail != null) {
final emailMap = {
'id': _attachedEmail!.id,
'subject': _attachedEmail!.subject,
'from': _attachedEmail!.from.map((e) => e.toString()).toList(),
'date': _attachedEmail!.sentAt?.toIso8601String() ??
_attachedEmail!.receivedAt.toIso8601String(),
'preview': _attachedEmail!.preview,
};
request.fields['email_data'] = jsonEncode(emailMap);
}
// Contact Email
if (_includeEmail) {
request.fields['email'] = _emailController.text;
}
// About Info
PackageInfo? pkg;
try {
pkg = await _packageInfoFuture;
} catch (_) {}
final imapCount =
_accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount =
_accounts.where((a) => a.type == AccountType.jmap).length;
if (!mounted) return;
final aboutInfo = buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: _deviceModel,
);
request.fields['about_info'] = aboutInfo;
// Sync Log
if (_includeSyncLog && _selectedAccountId != null) {
final syncLogs = await ref
.read(syncLogRepositoryProvider)
.observeSyncLogs(_selectedAccountId!)
.first;
request.fields['sync_log'] = _serializeSyncLogs(syncLogs);
}
// Attachments
for (final file in _attachments) {
final multipartFile = await http.MultipartFile.fromPath(
'attachments[]',
file.path!,
filename: file.name,
);
request.files.add(multipartFile);
}
final streamedResponse = await client.send(request);
final response = await http.Response.fromStream(streamedResponse);
if (!mounted) return;
if (response.statusCode == 201) {
final resData = jsonDecode(response.body) as Map<String, dynamic>;
final reportId = resData['id'] as String;
_showSuccessDialog(reportId);
} else if (response.statusCode == 429) {
final retryAfter = response.headers['retry-after'] ?? '6';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Rate limited. Please retry in $retryAfter seconds.'),
backgroundColor: Colors.orange,
),
);
} else {
String errorMsg =
'Failed to submit report. Server returned status: ${response.statusCode}';
try {
final resData = jsonDecode(response.body) as Map<String, dynamic>;
if (resData['error'] != null) {
errorMsg = resData['error'] as String;
}
} catch (_) {}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMsg),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('An error occurred: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _submitting = false);
}
}
}
void _showSuccessDialog(String reportId) {
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Bug Report Submitted'),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text('Thank you for helping us improve SharedInbox!'),
const SizedBox(height: 12),
Text(
'Your Report ID is:\n$reportId',
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Your report is handled confidentially and has not been posted to the public issue tracker.',
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(); // Dismiss dialog
context.pop(); // Go back to previous screen
},
child: const Text('Close'),
),
],
);
},
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final totalSize = _totalAttachmentSize;
const sizeLimit = 20 * 1024 * 1024;
final approachingLimit = totalSize > 15 * 1024 * 1024;
return Scaffold(
appBar: AppBar(
title: const Text('Report a Bug'),
),
body: _loadingEmail
? const Center(child: CircularProgressIndicator())
: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Confidentiality info card
Card(
elevation: 0,
color: theme.colorScheme.secondaryContainer
.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(
side: BorderSide(
color:
theme.colorScheme.secondary.withValues(alpha: 0.4),
),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
Icons.lock_outline,
color: theme.colorScheme.secondary,
),
const SizedBox(width: 16),
const Expanded(
child: Text(
'Your report is handled confidentially and will not be posted to the public issue tracker.',
style: TextStyle(height: 1.3),
),
),
],
),
),
),
const SizedBox(height: 20),
// Description Text Field
TextFormField(
controller: _descriptionController,
autofocus: true,
maxLines: 8,
minLines: 4,
decoration: const InputDecoration(
labelText: 'What went wrong?',
alignLabelWithHint: true,
border: OutlineInputBorder(),
helperText:
'Please describe the problem and how to reproduce it.',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter a description.';
}
return null;
},
),
const SizedBox(height: 20),
// Email info chip if email is attached
if (_attachedEmail != null) ...[
Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
child: Row(
children: [
Icon(
Icons.email_outlined,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'The current email metadata will be attached automatically.',
style: TextStyle(fontSize: 13),
),
),
],
),
),
),
const SizedBox(height: 16),
],
// Attachments Section
Text(
'Attachments',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedButton.icon(
onPressed: _submitting ? null : _pickAttachments,
icon: const Icon(Icons.add_a_photo_outlined),
label: const Text('Add screenshots'),
),
const SizedBox(width: 16),
const Expanded(
child: Text(
'Screenshots help us understand the problem faster.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
],
),
if (_attachments.isNotEmpty) ...[
const SizedBox(height: 12),
SizedBox(
height: 48,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _attachments.length,
itemBuilder: (context, index) {
final file = _attachments[index];
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: InputChip(
label: Text(
'${file.name} (${_formatSize(file.size)})',
),
onDeleted: _submitting
? null
: () => _removeAttachment(index),
),
);
},
),
),
const SizedBox(height: 8),
Row(
children: [
Text(
'Total Attachment Size: ${_formatSize(totalSize)} / ${_formatSize(sizeLimit)}',
style: TextStyle(
fontSize: 12,
color: totalSize > sizeLimit
? Colors.red
: approachingLimit
? Colors.orange
: Colors.grey,
fontWeight: approachingLimit
? FontWeight.bold
: FontWeight.normal,
),
),
if (totalSize > sizeLimit) ...[
const SizedBox(width: 8),
const Icon(
Icons.error_outline,
size: 16,
color: Colors.red,
),
],
],
),
],
const SizedBox(height: 24),
// Email opt-in
CheckboxListTile(
title: const Text('Include my email for follow-up'),
value: _includeEmail,
onChanged: _submitting
? null
: (val) {
setState(() => _includeEmail = val ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
if (_includeEmail) ...[
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Contact Email Address',
border: OutlineInputBorder(),
),
validator: (value) {
if (_includeEmail &&
(value == null || value.trim().isEmpty)) {
return 'Please enter an email address.';
}
return null;
},
),
),
],
// Sync log opt-in
if (_selectedAccountId != null) ...[
CheckboxListTile(
title: const Text('Include recent sync log'),
subtitle: const Text(
'Helps diagnose connection and protocol issues.',
),
value: _includeSyncLog,
onChanged: _submitting
? null
: (val) {
setState(() => _includeSyncLog = val ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 12),
],
// System info section
FutureBuilder<PackageInfo>(
future: _packageInfoFuture,
builder: (context, snapshot) {
final imapCount = _accounts
.where((a) => a.type == AccountType.imap)
.length;
final jmapCount = _accounts
.where((a) => a.type == AccountType.jmap)
.length;
final aboutMd = buildAboutMarkdown(
context: context,
pkg: snapshot.data,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: _deviceModel,
);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.1),
),
borderRadius: BorderRadius.circular(8),
),
child: ExpansionTile(
title: const Text(
'System Info (attached automatically)',
style: TextStyle(fontSize: 14),
),
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: Align(
alignment: Alignment.topLeft,
child: MarkdownBody(data: aboutMd),
),
),
],
),
);
},
),
const SizedBox(height: 32),
// Submit Button
FilledButton(
onPressed: _submitting ? null : _submitReport,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: _submitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Send Bug Report',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
);
}
}
+142 -171
View File
@@ -3,12 +3,20 @@ import 'dart:async';
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 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
final _dateFmt = DateFormat('MMM d');
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
class CombinedInboxScreen extends ConsumerStatefulWidget {
const CombinedInboxScreen({super.key});
@@ -22,31 +30,6 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
static const _pageSize = 50;
int _limit = _pageSize;
// Thread-level selection (key = threadId).
final Set<String> _selectedThreadIds = {};
// Last-emitted thread list, used to resolve emailIds for batch operations.
List<EmailThread> _currentThreads = [];
bool get _selecting => _selectedThreadIds.isNotEmpty;
void _toggleThreadSelection(EmailThread thread) {
setState(() {
if (_selectedThreadIds.contains(thread.threadId)) {
_selectedThreadIds.remove(thread.threadId);
} else {
_selectedThreadIds.add(thread.threadId);
}
});
}
void _clearSelection() => setState(() => _selectedThreadIds.clear());
void _selectAll() {
setState(
() => _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)),
);
}
@override
Widget build(BuildContext context) {
final accountsAsync = ref.watch(allAccountsProvider);
@@ -75,38 +58,18 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
return Scaffold(
appBar: _buildAppBar(accounts),
drawer: _selecting ? null : _buildDrawer(context, accounts),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
drawer: _buildDrawer(context, accounts),
body: _buildBody(accountNames, showAccount),
floatingActionButton: _selecting
? null
: FloatingActionButton(
onPressed: () => context.push('/compose'),
child: const Icon(Icons.edit),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/compose'),
child: const Icon(Icons.edit),
),
);
},
);
}
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
if (_selecting) {
return AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _clearSelection,
),
title: Text('${_selectedThreadIds.length} selected'),
actions: [
IconButton(
icon: const Icon(Icons.select_all),
tooltip: 'Select all',
onPressed: _selectAll,
),
],
);
}
return AppBar(
title: const Text('Combined Inbox'),
actions: [
@@ -128,26 +91,6 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
);
}
Widget _selectionBottomBar() {
return BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
icon: const Icon(Icons.archive),
tooltip: 'Archive',
onPressed: _batchArchive,
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: _batchDelete,
),
],
),
);
}
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
return Drawer(
child: ListView(
@@ -233,7 +176,6 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
return const Center(child: CircularProgressIndicator());
}
final threads = snap.data!;
_currentThreads = threads;
if (threads.isEmpty) {
return ListView(
children: const [
@@ -265,33 +207,119 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
child: const Text('Load more'),
);
}
final t = threads[i];
return EmailThreadTile(
thread: t,
isSelected: _selectedThreadIds.contains(t.threadId),
isSelecting: _selecting,
showAccount: showAccount,
accountName: accountNames[t.accountId],
onTap: _selecting
? () => _toggleThreadSelection(t)
: t.messageCount > 1
? () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
onLongPress: () => _toggleThreadSelection(t),
onDismissed: (direction) => _onSwipeDismissed(t, direction),
);
return _buildThreadTile(ctx, threads[i], accountNames, showAccount);
},
);
}
Widget _buildThreadTile(
BuildContext ctx,
EmailThread t,
Map<String, String> accountNames,
bool showAccount,
) {
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color: t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(ctx).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
if (showAccount)
Text(
accountNames[t.accountId] ?? t.accountId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).colorScheme.primary,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
onTap: t.messageCount > 1
? () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${t.accountId}/mailboxes'
'/${Uri.encodeComponent(t.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
);
return Dismissible(
key: ValueKey('${t.accountId}:${t.threadId}'),
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: (direction) => unawaited(_onSwipeDismissed(t, direction)),
child: tile,
);
}
Future<void> _onSwipeDismissed(
EmailThread t,
DismissDirection direction,
@@ -342,81 +370,24 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchArchive() async {
final repo = ref.read(emailRepositoryProvider);
final mailboxRepo = ref.read(mailboxRepositoryProvider);
// Group selected threads by accountId so we look up each account's archive once.
final byAccount = <String, List<EmailThread>>{};
for (final t in _currentThreads) {
if (!_selectedThreadIds.contains(t.threadId)) continue;
(byAccount[t.accountId] ??= []).add(t);
}
_clearSelection();
for (final entry in byAccount.entries) {
final accountId = entry.key;
final threads = entry.value;
final archive = await mailboxRepo.findMailboxByRole(accountId, 'archive');
if (!mounted || archive == null) continue;
for (final t in threads) {
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: accountId,
type: UndoType.move,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
}
}
Future<void> _batchDelete() async {
final repo = ref.read(emailRepositoryProvider);
final selectedThreads = _currentThreads
.where((t) => _selectedThreadIds.contains(t.threadId))
.toList();
_clearSelection();
for (final t in selectedThreads) {
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: t.accountId,
type: UndoType.delete,
emailIds: t.emailIds,
sourceMailboxPath: t.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
+16 -199
View File
@@ -12,7 +12,6 @@ import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/note.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart';
@@ -38,7 +37,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
bool _isFlagged = false;
bool _loadRemoteImages = false;
final Set<String> _downloading = {};
bool _notesSynced = false;
@override
Widget build(BuildContext context) {
@@ -52,15 +50,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (email != null && mounted) {
setState(() => _isFlagged = email.isFlagged);
}
if (!_notesSynced && email?.messageId != null) {
_notesSynced = true;
unawaited(
ref.read(noteRepositoryProvider).syncNotes(
email!.accountId,
email.messageId!,
),
);
}
},
);
@@ -104,17 +93,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) {
await ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.delete,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [header],
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.delete,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [header],
),
),
);
);
}
if (context.mounted) _navigateTo(context, header, nextEmailId);
@@ -152,11 +143,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: Text('Show Mail Structure'),
),
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'bug_report',
child: Text('Report a Bug'),
),
],
onSelected: (value) async {
if (value == 'forward' && header != null) {
@@ -177,10 +163,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
_showStructure(context, body);
} else if (value == 'rfc') {
unawaited(_showRaw(context, header));
} else if (value == 'bug_report') {
unawaited(
context.push('/bug-report?emailId=${widget.emailId}'),
);
}
},
),
@@ -240,14 +222,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'View',
label: 'Settings',
onPressed: () {
if (mounted) {
unawaited(
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
context.push('/accounts/preferences'),
);
}
},
@@ -268,7 +247,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
body.textBody ?? '',
style: Theme.of(ctx).textTheme.bodyMedium,
),
if (header?.messageId != null) _buildNotesSection(ctx, header!),
if (body.attachments.isNotEmpty) ...[
const Divider(),
Padding(
@@ -352,114 +330,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
}
}
Widget _buildNotesSection(BuildContext ctx, Email header) {
final messageId = header.messageId!;
final notes = ref.watch(notesProvider((header.accountId, messageId)));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(),
Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
'Notes',
style: Theme.of(ctx).textTheme.titleSmall,
),
),
const Spacer(),
TextButton.icon(
icon: const Icon(Icons.add, size: 16),
label: const Text('Add'),
onPressed: () => unawaited(_addNoteDialog(ctx, header)),
),
],
),
notes.when(
loading: () => const SizedBox.shrink(),
error: (e, _) => Text('Error loading notes: $e'),
data: (list) {
if (list.isEmpty) {
return const Padding(
padding: EdgeInsets.only(bottom: 4),
child: Text(
'No notes yet.',
style: TextStyle(color: Colors.grey),
),
);
}
return Column(
children: [
for (final note in list) _buildNoteRow(ctx, note),
],
);
},
),
],
);
}
Widget _buildNoteRow(BuildContext ctx, EmailNote note) {
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(note.noteText),
subtitle: Text(
DateFormat('MMM d, HH:mm').format(note.createdAt),
style: Theme.of(ctx).textTheme.bodySmall,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: 'Delete note',
onPressed: () {
unawaited(ref.read(noteRepositoryProvider).deleteNote(note.id));
},
),
);
}
Future<void> _addNoteDialog(BuildContext context, Email header) async {
final messageId = header.messageId;
if (messageId == null) return;
final ctrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Add note'),
content: TextField(
controller: ctrl,
autofocus: true,
maxLines: 4,
decoration: const InputDecoration(hintText: 'Type a note…'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Save'),
),
],
),
);
final text = ctrl.text.trim();
ctrl.dispose();
if (confirmed != true || text.isEmpty) return;
if (!context.mounted) return;
await ref.read(noteRepositoryProvider).addNote(
header.accountId,
messageId,
text,
);
}
Widget _buildHeader(BuildContext ctx, Email email) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -683,42 +553,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
Future<String?> _promptNewFolderName(BuildContext context) async {
final controller = TextEditingController();
try {
return await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Create new folder'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(hintText: 'Folder name'),
textCapitalization: TextCapitalization.words,
onSubmitted: (value) {
if (value.trim().isNotEmpty) Navigator.pop(ctx, value.trim());
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = controller.text.trim();
if (name.isNotEmpty) Navigator.pop(ctx, name);
},
child: const Text('Create'),
),
],
),
);
} finally {
controller.dispose();
}
}
Future<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
@@ -732,8 +566,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (!context.mounted) return;
const createNewSentinel = '__create_new__';
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
@@ -751,28 +583,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
ListTile(
leading: const Icon(Icons.create_new_folder_outlined),
title: const Text('Create new folder…'),
onTap: () => Navigator.pop(ctx, createNewSentinel),
),
],
),
);
if (chosen == null || !context.mounted) return;
String destination = chosen;
if (chosen == createNewSentinel) {
final name = await _promptNewFolderName(context);
if (name == null || !context.mounted) return;
final mailbox = await mailboxRepo.createMailbox(header.accountId, name);
destination = mailbox.path;
}
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, destination);
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
@@ -782,7 +599,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: destination,
destinationMailboxPath: chosen,
),
),
);
+183 -69
View File
@@ -12,10 +12,19 @@ import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
final _dateFmt = DateFormat('MMM d');
// Cache formatted dates by local calendar day so DateFormat.format is called
// at most once per unique date rather than once per list item per rebuild.
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({
@@ -679,93 +688,177 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
final t = threads[i];
return EmailThreadTile(
thread: t,
isSelected: _selectedThreadIds.contains(t.threadId),
isSelecting: _selecting,
final isSelected = _selectedThreadIds.contains(t.threadId);
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: SizedBox(
width: 40,
child: _selecting
? Checkbox(
value: isSelected,
onChanged: (_) => _toggleThreadSelection(t),
)
: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color:
t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
),
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(ctx).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
selected: isSelected,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall,
),
],
),
onTap: _selecting
? () => _toggleThreadSelection(t)
: t.messageCount > 1
? () => context.push(
'/accounts/${widget.accountId}/mailboxes'
'/${Uri.encodeComponent(widget.mailboxPath)}'
'/threads/${Uri.encodeComponent(t.threadId)}',
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
)
: () => context.push(
'/accounts/${widget.accountId}/mailboxes'
'/${Uri.encodeComponent(widget.mailboxPath)}'
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
),
onLongPress: () => _toggleThreadSelection(t),
onDismissed: (direction) => _onSwipeDismissed(t, direction),
);
// For swipe actions on threads, operate on the latest email only
// (single-email threads) or the whole thread.
return Dismissible(
key: ValueKey(t.threadId),
direction:
_selecting ? DismissDirection.none : DismissDirection.horizontal,
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: (direction) async {
final repo = ref.read(emailRepositoryProvider);
final type = direction == DismissDirection.startToEnd
? UndoType.move
: UndoType.delete;
// Fetch full email data before moving/deleting.
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
} else {
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(action),
);
}
},
child: tile,
);
},
);
}
Future<void> _onSwipeDismissed(
EmailThread t,
DismissDirection direction,
) async {
final repo = ref.read(emailRepositoryProvider);
final type = direction == DismissDirection.startToEnd
? UndoType.move
: UndoType.delete;
// Fetch full email data before moving/deleting.
final originalEmails = (await Future.wait(
t.emailIds.map((id) => repo.getEmail(id)),
))
.whereType<Email>()
.toList();
if (direction == DismissDirection.startToEnd) {
final archive = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, 'archive');
if (!mounted || archive == null) return;
for (final id in t.emailIds) {
await repo.moveEmail(id, archive.path);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: archive.path,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
return;
}
String? lastDestPath;
for (final id in t.emailIds) {
lastDestPath = await repo.deleteEmail(id);
}
final action = UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.accountId,
type: type,
emailIds: t.emailIds,
sourceMailboxPath: widget.mailboxPath,
destinationMailboxPath: lastDestPath,
originalEmails: originalEmails,
);
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
// Used for search results, which are individual emails.
Widget _buildEmailList(List<Email> emails) {
return ListView.builder(
itemCount: emails.length,
itemBuilder: (ctx, i) {
final e = emails[i];
final t = EmailThread.fromEmail(e);
final isSelected = _selectedSearchIds.contains(e.id);
return ThreadTile(
thread: t,
return EmailTile(
email: e,
selected: isSelected,
leading: SizedBox(
width: 40,
@@ -784,4 +877,25 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
},
);
}
Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
+4 -6
View File
@@ -51,12 +51,10 @@ class MailboxListScreen extends ConsumerWidget {
? BottomAppBar(
child: Row(
children: [
Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
],
),
+4 -4
View File
@@ -8,7 +8,7 @@ import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
ref,
@@ -189,9 +189,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
if (r.emails.isNotEmpty) ...[
const _SectionHeader('Messages'),
for (final e in r.emails)
ThreadTile(
thread: EmailThread.fromEmail(e),
locationLabel: '${e.accountId}${e.mailboxPath}',
EmailTile(
email: e,
showLocation: true,
onTap: () => context.push(
'/accounts/${e.accountId}/mailboxes'
'/${Uri.encodeComponent(e.mailboxPath)}'
+41 -24
View File
@@ -217,14 +217,11 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'View',
label: 'Settings',
onPressed: () {
if (mounted) {
unawaited(
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
context.push('/accounts/preferences'),
);
}
},
@@ -300,27 +297,47 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
}
Future<void> _delete() async {
final repo = ref.read(emailRepositoryProvider);
// Fetch data first for IMAP undo support
final original = await repo.getEmail(widget.email.id);
final destPath = await repo.deleteEmail(widget.email.id);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete email'),
content: const Text('Move this email to Trash?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete'),
),
],
),
);
if (!mounted) return;
if (original != null) {
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.email.accountId,
type: UndoType.delete,
emailIds: [widget.email.id],
sourceMailboxPath: widget.email.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [original],
if (confirmed == true) {
final repo = ref.read(emailRepositoryProvider);
// Fetch data first for IMAP undo support
final original = await repo.getEmail(widget.email.id);
final destPath = await repo.deleteEmail(widget.email.id);
if (!mounted) return;
if (original != null) {
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: widget.email.accountId,
type: UndoType.delete,
emailIds: [widget.email.id],
sourceMailboxPath: widget.email.mailboxPath,
destinationMailboxPath: destPath,
originalEmails: [original],
),
),
),
);
);
}
}
}
}
@@ -1,63 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/di.dart';
class TrustedImageSendersScreen extends ConsumerWidget {
const TrustedImageSendersScreen({super.key, this.highlightedSender});
final String? highlightedSender;
@override
Widget build(BuildContext context, WidgetRef ref) {
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
return Scaffold(
appBar: AppBar(title: const Text('Allowed addresses for images')),
body: trustedSendersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Center(child: Text('Error loading trusted senders')),
data: (senders) {
if (senders.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Text(
'No addresses added yet. '
'Tap "Load remote images" in an email to add the sender.',
),
);
}
return ListView.builder(
itemCount: senders.length,
itemBuilder: (context, index) {
final sender = senders[index];
final isHighlighted = sender == highlightedSender;
return ListTile(
title: Text(
sender,
style: isHighlighted
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
onPressed: () {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.removeTrustedImageSender(sender),
);
},
),
);
},
);
},
),
);
}
}
+30 -92
View File
@@ -2,10 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/sync/background_sync.dart';
import 'package:sharedinbox/di.dart';
class UserPreferencesScreen extends ConsumerWidget {
@@ -15,7 +13,6 @@ class UserPreferencesScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final prefsAsync = ref.watch(userPreferencesProvider);
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
final trustedCount = trustedSendersAsync.value?.length ?? 0;
return Scaffold(
appBar: AppBar(title: const Text('Preferences')),
@@ -138,104 +135,45 @@ class UserPreferencesScreen extends ConsumerWidget {
const Divider(),
ListTile(
title: Text(
'Offline email cache',
'Trusted image senders',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Pre-fetch email bodies in the background so they are available offline.',
'Remote images are loaded automatically for these senders.',
),
),
RadioGroup<PrefetchMode>(
groupValue: prefs.prefetchMode,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updatePrefetchMode(value),
);
unawaited(registerBodyPrefetchTask(value));
},
child: const Column(
children: [
RadioListTile<PrefetchMode>(
title: Text('Wi-Fi only (default)'),
subtitle: Text(
'Pre-fetch bodies in the background when connected to Wi-Fi.',
),
value: PrefetchMode.wifiOnly,
),
RadioListTile<PrefetchMode>(
title: Text('Any network'),
subtitle: Text(
'Pre-fetch bodies on Wi-Fi and mobile data.',
),
value: PrefetchMode.always,
),
RadioListTile<PrefetchMode>(
title: Text('Disabled'),
subtitle: Text(
'Do not pre-fetch email bodies in the background.',
),
value: PrefetchMode.disabled,
),
],
),
),
if (prefs.prefetchMode != PrefetchMode.disabled) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Text('Cache size limit:'),
const SizedBox(width: 16),
DropdownButton<int>(
value: _nearestCacheOption(prefs.bodyCacheLimitMb),
items: const [
DropdownMenuItem(value: 50, child: Text('50 MB')),
DropdownMenuItem(value: 100, child: Text('100 MB')),
DropdownMenuItem(value: 200, child: Text('200 MB')),
DropdownMenuItem(value: 500, child: Text('500 MB')),
],
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateBodyCacheLimitMb(value),
);
},
),
],
),
),
const SizedBox(height: 8),
],
const Divider(),
ListTile(
title: Text(
'Allowed addresses for images',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: Text(
trustedCount == 0
? 'No addresses added yet.'
: '$trustedCount address${trustedCount == 1 ? '' : 'es'}',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/accounts/trusted-senders'),
...trustedSendersAsync.when(
loading: () => const [],
error: (_, __) => const [],
data: (senders) => senders.isEmpty
? [
const Padding(
padding:
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No trusted senders yet.'),
),
]
: [
for (final sender in senders)
ListTile(
title: Text(sender),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Remove',
onPressed: () {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.removeTrustedImageSender(sender),
);
},
),
),
],
),
],
),
),
);
}
int _nearestCacheOption(int mb) {
const options = [50, 100, 200, 500];
return options.reduce(
(a, b) => (a - mb).abs() <= (b - mb).abs() ? a : b,
);
}
}
-171
View File
@@ -1,171 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
final _dateFmt = DateFormat('MMM d');
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
/// A swipeable list tile for an [EmailThread].
///
/// Handles the [Dismissible] wrapper (archive left, delete right) and
/// selection-mode checkbox. Pass [showAccount] to display an extra subtitle
/// line with the account name — used in the combined-inbox view.
class EmailThreadTile extends StatelessWidget {
const EmailThreadTile({
super.key,
required this.thread,
required this.isSelected,
required this.isSelecting,
required this.onTap,
required this.onLongPress,
required this.onDismissed,
this.showAccount = false,
this.accountName,
});
final EmailThread thread;
final bool isSelected;
final bool isSelecting;
final VoidCallback onTap;
final VoidCallback onLongPress;
final Future<void> Function(DismissDirection) onDismissed;
/// When true, renders an extra subtitle line with [accountName].
final bool showAccount;
final String? accountName;
@override
Widget build(BuildContext context) {
final t = thread;
final senderNames =
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
final tile = ListTile(
leading: SizedBox(
width: 40,
child: isSelecting
? Checkbox(
value: isSelected,
onChanged: (_) => onTap(),
)
: Icon(
t.hasUnread ? Icons.mail : Icons.mail_outline,
color:
t.hasUnread ? Theme.of(context).colorScheme.primary : null,
),
),
title: Row(
children: [
Expanded(
child: Text(
senderNames.isEmpty ? '(unknown)' : senderNames,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (t.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${t.messageCount}]',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (t.preview != null && t.preview!.isNotEmpty)
Text(
t.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
if (showAccount && accountName != null)
Text(
accountName!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
selected: isSelected,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (t.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(t.latestDate),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
onTap: onTap,
onLongPress: onLongPress,
);
return Dismissible(
key: ValueKey('${t.accountId}:${t.threadId}'),
direction:
isSelecting ? DismissDirection.none : DismissDirection.horizontal,
background: _swipeBackground(
alignment: Alignment.centerLeft,
color: Colors.green,
icon: Icons.archive,
label: 'Archive',
),
secondaryBackground: _swipeBackground(
alignment: Alignment.centerRight,
color: Colors.red,
icon: Icons.delete,
label: 'Delete',
),
onDismissed: onDismissed,
child: tile,
);
}
static Widget _swipeBackground({
required AlignmentGeometry alignment,
required Color color,
required IconData icon,
required String label,
}) {
return Container(
color: color,
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white),
const SizedBox(width: 8),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);
}
}
-121
View File
@@ -1,121 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
final _dateFmt = DateFormat('MMM d');
// Cache formatted dates by local calendar day to avoid repeated DateFormat.format calls.
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
/// A list tile for an [EmailThread].
///
/// Used in inbox lists, combined inbox, and search result lists.
/// Pass a custom [leading] widget to support selection-mode checkboxes.
/// Pass [locationLabel] to show an extra subtitle line (e.g. account name or
/// "accountId • mailboxPath") — useful in cross-mailbox views.
class ThreadTile extends StatelessWidget {
const ThreadTile({
super.key,
required this.thread,
required this.onTap,
this.leading,
this.selected = false,
this.onLongPress,
this.locationLabel,
});
final EmailThread thread;
final VoidCallback onTap;
final Widget? leading;
final bool selected;
final VoidCallback? onLongPress;
/// When non-null, appended as an extra subtitle line in primary colour.
final String? locationLabel;
@override
Widget build(BuildContext context) {
final senderNames = thread.participants.isEmpty
? '(unknown)'
: thread.participants.map((a) => a.name ?? a.email).take(3).join(', ');
return ListTile(
leading: leading ??
Icon(
thread.hasUnread ? Icons.mail : Icons.mail_outline,
color:
thread.hasUnread ? Theme.of(context).colorScheme.primary : null,
),
title: Row(
children: [
Expanded(
child: Text(
senderNames,
style: thread.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
overflow: TextOverflow.ellipsis,
),
),
if (thread.messageCount > 1)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'[${thread.messageCount}]',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
thread.subject ?? '(no subject)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: thread.hasUnread
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
if (thread.preview != null && thread.preview!.isNotEmpty)
Text(
thread.preview!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
if (locationLabel != null)
Text(
locationLabel!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (thread.isFlagged)
const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4),
Text(
_fmtDate(thread.latestDate),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
selected: selected,
onTap: onTap,
onLongPress: onLongPress,
);
}
}
+66
View File
@@ -0,0 +1,66 @@
# Next
## Introduction
Continue the momentum from the safety hardening and infrastructure work.
The focus is on making the app ready for real-world use with robust error
handling and performance optimizations.
Create several small commits. Every commit should be self contained.
while working create/append to plan.log, so that the user sees what you are working on.
## Tasks
### 0. deploy-android
Make `task deploy-android` work.
### 0.5 Debug duration of deploy-android
Is there a way to make deploy-android faster?
Use `task --verbose` to see what gets done.
Maybe avoid doing things again, when nothing changed.
Taskfile has features to avoid calling things again, when the input has not changed.
### 1. Fix Android E2E Race Condition (aliceTile)
The Android E2E test `integration_test/app_e2e_test.dart` is flaky. It fails
at `tap(aliceTile)` with "0 widgets" even though `pumpUntil` found it.
The current "double pumpUntil" fix isn't reliable enough.
Investigate if the animation state or the Drift stream propagation is the
culprit.
### 2. Implement Global Crash Screen
Wrap `main()` in `runZonedGuarded` to catch unhandled async errors.
Implement a `CrashScreen` widget that shows the stack trace and a
"Copy to Clipboard" button for user reporting.
### 3. Database-Backed Threading
Currently, emails are grouped into threads in-memory in the repository.
Refactor to store thread relationships in the local SQLite database.
This is necessary for performance on mailboxes with thousands of messages.
### 4. Implement Undo for Bulk Actions
Add a global "Undo" snackbar after deleting or moving emails.
The system needs to handle the three sync states:
- Queued (easy to undo)
- In-progress (cancel network call)
- Finished (requires a reverse move/un-delete)
### 5. Transition to Real Account Testing
Prepare the integration tests to run against a real test account
(`si3e2e@thomas-guettler.de`) instead of the local Stalwart server.
This verifies the app against real-world network latency and RFC edge cases.
### 6. Coverage Gate Maintenance
Reduce the `_excluded` list in `scripts/check_coverage.dart`.
Add a test to ensure the exclusion list doesn't contain files that no longer
exist ("ghost paths").
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

+9 -9
View File
@@ -659,10 +659,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: "direct main"
description:
@@ -1021,7 +1021,7 @@ packages:
source: hosted
version: "0.44.4"
stack_trace:
dependency: "direct main"
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
@@ -1088,26 +1088,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
version: "1.31.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
version: "0.6.17"
timezone:
dependency: transitive
description:
-3
View File
@@ -58,9 +58,6 @@ dependencies:
flutter_local_notifications: ^21.0.0
workmanager: ^0.9.0
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
stack_trace: ^1.12.1
# App version metadata for crash reports
package_info_plus: ^10.1.0
share_plus: ^13.1.0
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp /dev/shm/keystore.XXXXXX.jks)
trap "rm -f $tmp" EXIT
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$tmp"
ANDROID_KEYSTORE_PATH="$tmp" \
ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" \
fvm flutter build appbundle --release --no-pub \
--build-number "$(date +%s)" \
--build-name "$(date +%y%m%d-%H%M)" \
--dart-define="GIT_HASH=$(git rev-parse --short HEAD)" \
| grep -Ev "was tree-shaken|Tree-shaking can be disabled"
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env bash
# Verify that every container image referenced in ci/main.go is reachable.
# Runs skopeo inspect (manifest-only, no layer pull) for each From("...") call.
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
FILE="$ROOT/ci/main.go"
# Static images from From("...") literals in ci/main.go
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | grep -v ':$' | sort -u)
# Dynamic Flutter image derived from .fvmrc (not a literal in main.go)
FVMRC="$ROOT/.fvmrc"
flutter_version=$(python3 -c "import json; print(json.load(open('$FVMRC'))['flutter'])" 2>/dev/null || true)
flutter_image=""
if [ -n "$flutter_version" ]; then
flutter_image="ghcr.io/cirruslabs/flutter:$flutter_version"
fi
images=$(printf '%s\n%s\n' "$static_images" "$flutter_image" | grep -v '^$' | sort -u)
if [ -z "$images" ]; then
echo "check-ci-images: no From() image references found in $FILE"
exit 0
fi
fail=0
while IFS= read -r image; do
printf "check-ci-images: %-55s" "$image"
if skopeo inspect --no-creds "docker://$image" > /dev/null 2>&1; then
echo "OK"
else
echo "NOT FOUND"
fail=1
fi
done <<< "$images"
if [ "$fail" -eq 1 ]; then
echo ""
echo "ERROR: one or more container images in ci/main.go could not be resolved."
echo "Fix the image tag before committing."
exit 1
fi
-8
View File
@@ -23,8 +23,6 @@ const _noCode = {
'lib/core/repositories/user_preferences_repository.dart',
'lib/core/models/undo_action.dart',
'lib/core/models/user_preferences.dart',
'lib/core/models/note.dart',
'lib/core/repositories/note_repository.dart',
'lib/core/storage/secure_storage.dart',
};
@@ -43,9 +41,7 @@ const _excluded = {
'lib/ui/screens/account_send_screen.dart',
'lib/ui/screens/add_account_screen.dart',
'lib/ui/screens/address_emails_screen.dart',
'lib/ui/screens/bug_report_screen.dart',
'lib/ui/screens/changelog_screen.dart',
'lib/ui/screens/combined_inbox_screen.dart',
'lib/ui/screens/compose_screen.dart',
'lib/ui/screens/crash_screen.dart',
'lib/ui/screens/edit_account_screen.dart',
@@ -83,10 +79,6 @@ const _excluded = {
'lib/data/repositories/user_preferences_repository_impl.dart',
'lib/ui/screens/user_preferences_screen.dart',
'lib/core/services/update_service.dart',
'lib/ui/widgets/email_thread_tile.dart',
'lib/ui/screens/trusted_image_senders_screen.dart',
'lib/data/repositories/note_repository_impl.dart',
'lib/ui/widgets/thread_tile.dart',
};
void main() {
+1 -5
View File
@@ -34,7 +34,7 @@ _filter_noise() {
_run() {
: > "$OUT" ; : > "$RC_FILE"
{
timeout --kill-after=10 2400 dagger call --progress=plain -q -m ci --source=. test-android-firebase \
dagger call --progress=plain -q -m ci --source=. test-android-firebase \
--service-account-key env:FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY \
--project-id "$FIREBASE_PROJECT_ID"
echo $? > "$RC_FILE"
@@ -44,10 +44,6 @@ _run() {
for attempt in 1 2 3; do
_run && break
RC=$(cat "$RC_FILE" 2>/dev/null || echo 1)
if [ "$RC" -eq 124 ]; then
echo "::warning::[firebase] attempt $attempt/3 timed out after 2400s" >&2
exit 124
fi
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|No Dagger server responded" "$OUT"; then
echo "[firebase] dagger connectivity error on attempt $attempt/3, retrying..." >&2
else
+2 -14
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
[ "${CI:-}" = "true" ] || [ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
if [ -z "${SOPS_AGE_KEY:-}" ]; then
echo "Error: SOPS_AGE_KEY must be set."
@@ -51,27 +50,16 @@ export_secret "RENOVATE_FORGEJO_TOKEN"
# Setup SSH directory and keys
mkdir -p ~/.ssh
chmod 700 ~/.ssh
rm -f ~/.ssh/dagger_key
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
chmod 600 ~/.ssh/dagger_key
# Add remote host to known_hosts
_t0=$SECONDS
timeout 30 ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
_elapsed=$(( SECONDS - _t0 ))
if [ "$_elapsed" -gt 10 ]; then
echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond"
fi
ssh-keyscan -H "$DAGGER_ENGINE_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# Create a background SSH tunnel to the Dagger engine.
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
_t0=$SECONDS
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
_elapsed=$(( SECONDS - _t0 ))
if [ "$_elapsed" -gt 10 ]; then
echo "::warning::SSH tunnel setup took ${_elapsed}s"
fi
ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
# Export _EXPERIMENTAL_DAGGER_RUNNER_HOST to use the tunnel.
export _EXPERIMENTAL_DAGGER_RUNNER_HOST="tcp://localhost:8080"
-266
View File
@@ -1,266 +0,0 @@
package main
import (
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
// BugReport represents the data stored in report.json
type BugReport struct {
Description string `json:"description"`
AboutInfo string `json:"about_info"`
EmailData string `json:"email_data,omitempty"`
SyncLog string `json:"sync_log,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
var (
rateLimitMu sync.Mutex
requestTimes []time.Time
)
// checkRateLimit implements a sliding window rate limiter: max 10 requests per minute globally.
func checkRateLimit() (bool, time.Duration) {
rateLimitMu.Lock()
defer rateLimitMu.Unlock()
now := time.Now()
// Clean up timestamps older than 1 minute
var valid []time.Time
for _, t := range requestTimes {
if now.Sub(t) < time.Minute {
valid = append(valid, t)
}
}
requestTimes = valid
if len(requestTimes) >= 10 {
// Calculate time until the oldest request in the window falls out of it
oldest := requestTimes[0]
remaining := time.Minute - now.Sub(oldest)
if remaining < 0 {
remaining = 0
}
return false, remaining
}
requestTimes = append(requestTimes, now)
return true, 0
}
func generateUUID() (string, error) {
b := make([]byte, 16)
_, err := rand.Read(b)
if err != nil {
return "", err
}
// Format as UUID v4 structure
b[6] = (b[6] & 0x0f) | 0x40 // Version 4
b[8] = (b[8] & 0x3f) | 0x80 // Variant is 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
}
func bugReportHandler(storageDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Enable CORS so the web app (if applicable) can upload
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// Rate limiting check
allowed, waitTime := checkRateLimit()
if !allowed {
retryAfter := int(waitTime.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Too many requests. Please try again later."})
return
}
// Limit body size to 20 MB (20 * 1024 * 1024 bytes)
const maxBodySize = 20 * 1024 * 1024
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
// Parse the multipart form
err := r.ParseMultipartForm(maxBodySize)
if err != nil {
log.Printf("Failed to parse multipart form: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusRequestEntityTooLarge)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Request body too large or invalid multipart form."})
return
}
defer func() {
_ = r.MultipartForm.RemoveAll()
}()
description := r.FormValue("description")
aboutInfo := r.FormValue("about_info")
if description == "" || aboutInfo == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "description and about_info are required fields."})
return
}
email := r.FormValue("email")
emailData := r.FormValue("email_data")
syncLog := r.FormValue("sync_log")
uuidVal, err := generateUUID()
if err != nil {
log.Printf("Failed to generate UUID: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
now := time.Now()
timestampStr := now.Format("20060102_150405")
dirName := fmt.Sprintf("%s_%s", timestampStr, uuidVal)
reportDir := filepath.Join(storageDir, dirName)
err = os.MkdirAll(reportDir, 0750)
if err != nil {
log.Printf("Failed to create report directory: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Write report.json
report := BugReport{
Description: description,
AboutInfo: aboutInfo,
EmailData: emailData,
SyncLog: syncLog,
Timestamp: now,
}
reportJSONPath := filepath.Join(reportDir, "report.json")
reportJSONFile, err := os.OpenFile(reportJSONPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
log.Printf("Failed to create report.json: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer reportJSONFile.Close()
enc := json.NewEncoder(reportJSONFile)
enc.SetIndent("", " ")
err = enc.Encode(report)
if err != nil {
log.Printf("Failed to write report.json: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Write contact email to mail.eml (kept separate from report.json to isolate PII)
if email != "" {
mailEmlPath := filepath.Join(reportDir, "mail.eml")
err = os.WriteFile(mailEmlPath, []byte(email), 0600)
if err != nil {
log.Printf("Failed to write mail.eml: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
// Save attachments
form := r.MultipartForm
files := form.File["attachments[]"]
for i, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
log.Printf("Failed to open attachment %d: %v", i, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer file.Close()
// Sanitize filename to avoid directory traversal
baseName := filepath.Base(fileHeader.Filename)
attachmentName := fmt.Sprintf("attachment_%d_%s", i, baseName)
attachmentPath := filepath.Join(reportDir, attachmentName)
destFile, err := os.OpenFile(attachmentPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
log.Printf("Failed to create attachment file %s: %v", attachmentName, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer destFile.Close()
_, err = io.Copy(destFile, file)
if err != nil {
log.Printf("Failed to copy attachment content to %s: %v", attachmentName, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": uuidVal})
}
}
func main() {
port := os.Getenv("BUGREPORT_PORT")
if port == "" {
port = "8090"
}
storageDir := os.Getenv("BUGREPORT_STORAGE_DIR")
if storageDir == "" {
storageDir = "./reports"
}
// Create storage directory if it doesn't exist
err := os.MkdirAll(storageDir, 0750)
if err != nil {
log.Fatalf("Failed to create storage directory %s: %v", storageDir, err)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/bug-reports", bugReportHandler(storageDir))
addr := net.JoinHostPort("127.0.0.1", port)
log.Printf("Bug report server starting on %s...", addr)
log.Printf("Reports storage directory: %s", storageDir)
server := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
if err := server.ListenAndServe(); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
-1
View File
@@ -7,7 +7,6 @@
# Run inside nix develop:
# stalwart-dev/integration_android_test.sh
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
_SCRIPT_START=$(date +%s%3N)
ts() { echo "[$(( $(date +%s%3N) - _SCRIPT_START ))ms] $*"; }
-1
View File
@@ -5,7 +5,6 @@
#
# Run inside nix develop: stalwart-dev/integration_ui_test.sh
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
# Timing helper: prints elapsed seconds since script start with a label.
_SCRIPT_START=$(date +%s%3N)
-1
View File
@@ -2,7 +2,6 @@
# Starts Stalwart in the background on fresh random ports, runs Flutter
# integration tests, then stops it.
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
trap 'echo "Warning: A command failed ($0:$LINENO)"; exit 3' ERR
export STALWART_USER_B="${STALWART_USER_B:-alice@example.com}"
@@ -169,15 +169,6 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
-43
View File
@@ -1,43 +0,0 @@
// Loads Material fonts (Roboto + MaterialIcons) before any test runs so that
// golden/screenshot tests render real text instead of placeholder boxes.
//
// Flutter widget tests don't load fonts by default. This file is discovered
// automatically by `flutter test` for every test under test/.
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
setUpAll(_loadMaterialFonts);
await testMain();
}
Future<void> _loadMaterialFonts() async {
// Locate Flutter's cached material fonts relative to the flutter_tester executable.
// Layout: <flutter-root>/bin/cache/artifacts/engine/linux-x64/flutter_tester
// <flutter-root>/bin/cache/artifacts/material_fonts/
final cacheDir =
File(Platform.resolvedExecutable).parent.parent.parent.parent;
final fontsDir = '${cacheDir.path}/artifacts/material_fonts';
Future<ByteData> load(String name) async {
final bytes = await File('$fontsDir/$name').readAsBytes();
return ByteData.view(bytes.buffer);
}
await (FontLoader('Roboto')
..addFont(load('Roboto-Regular.ttf'))
..addFont(load('Roboto-Medium.ttf'))
..addFont(load('Roboto-Bold.ttf'))
..addFont(load('Roboto-Italic.ttf'))
..addFont(load('Roboto-MediumItalic.ttf'))
..addFont(load('Roboto-BoldItalic.ttf')))
.load();
await (FontLoader('MaterialIcons')
..addFont(load('MaterialIcons-Regular.otf')))
.load();
}
-427
View File
@@ -1,427 +0,0 @@
// Generates Play Store promotional screenshots for all three device classes.
//
// Run with:
// fvm flutter test test/screenshot_automation_test.dart --update-goldens
//
// Output: screenshots/{phone,tablet_7in,tablet_10in}/{light,dark}/<scene>.png
// at the repository root (one directory above test/).
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
import 'widget/helpers.dart';
// ---------------------------------------------------------------------------
// Device configurations
// ---------------------------------------------------------------------------
typedef _Device = ({String name, double width, double height, double dpr});
const _devices = <_Device>[
(name: 'phone', width: 1080.0, height: 1920.0, dpr: 3.0),
(name: 'tablet_7in', width: 1200.0, height: 1920.0, dpr: 2.0),
(name: 'tablet_10in', width: 1600.0, height: 2560.0, dpr: 2.0),
];
// ---------------------------------------------------------------------------
// Sample data — fixed date so golden files are stable between runs
// ---------------------------------------------------------------------------
const _kAccount = Account(
id: 'acc-1',
displayName: 'Alice',
email: 'alice@sharedinbox.de',
imapHost: 'imap.sharedinbox.de',
smtpHost: 'smtp.sharedinbox.de',
);
final _kDate = DateTime(2025, 5, 14, 10, 30);
Email _email({
required String id,
required String subject,
required String fromName,
required String fromEmail,
bool isSeen = true,
bool isFlagged = false,
bool hasAttachment = false,
String? preview,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: int.parse(id.split(':').last),
subject: subject,
receivedAt: _kDate,
sentAt: _kDate,
from: [EmailAddress(name: fromName, email: fromEmail)],
to: const [EmailAddress(name: 'Alice', email: 'alice@sharedinbox.de')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: hasAttachment,
preview: preview,
);
final _sampleEmails = [
_email(
id: 'acc-1:1',
subject: 'Re: Project kick-off next week',
fromName: 'Maria Hoffmann',
fromEmail: 'maria@corp.example',
isSeen: false,
preview: 'Sounds great! I will prepare the slides beforehand.',
),
_email(
id: 'acc-1:2',
subject: 'Your invoice #2024-0312 is ready',
fromName: 'Billing',
fromEmail: 'billing@service.example',
isSeen: false,
preview: 'Your invoice for May is attached as a PDF.',
),
_email(
id: 'acc-1:3',
subject: 'Team lunch — Friday 12:30',
fromName: 'Thomas Müller',
fromEmail: 'thomas@corp.example',
isFlagged: true,
preview: 'The Italian place on Main Street. RSVP by Thursday please.',
),
_email(
id: 'acc-1:4',
subject: 'Quarterly review agenda',
fromName: 'HR Team',
fromEmail: 'hr@corp.example',
preview:
"Please find the agenda for next week's quarterly review attached.",
),
_email(
id: 'acc-1:5',
subject: 'Weekend hiking trip — photos inside',
fromName: 'Jonas Weber',
fromEmail: 'jonas@personal.example',
hasAttachment: true,
preview: 'Had such a great time! Here are the photos from Saturday.',
),
_email(
id: 'acc-1:6',
subject: 'Reminder: dentist appointment tomorrow',
fromName: 'City Dental',
fromEmail: 'noreply@citydental.example',
preview: 'Your appointment is confirmed for Thursday at 14:00.',
),
_email(
id: 'acc-1:7',
subject: 'Re: Feedback on the draft',
fromName: 'Laura Schmidt',
fromEmail: 'laura@corp.example',
isSeen: false,
preview: 'I left some comments on page 3. Overall it looks really solid!',
),
_email(
id: 'acc-1:8',
subject: 'Flight confirmation PNR XYZ123',
fromName: 'Sunshine Airlines',
fromEmail: 'noreply@airline.example',
preview:
'Your booking is confirmed. Check-in opens 24 hours before departure.',
),
];
final _sampleMailboxes = [
const Mailbox(
id: 'acc-1:INBOX',
accountId: 'acc-1',
path: 'INBOX',
name: 'INBOX',
role: 'inbox',
unreadCount: 3,
totalCount: 8,
),
const Mailbox(
id: 'acc-1:Sent',
accountId: 'acc-1',
path: 'Sent',
name: 'Sent',
role: 'sent',
unreadCount: 0,
totalCount: 42,
),
const Mailbox(
id: 'acc-1:Drafts',
accountId: 'acc-1',
path: 'Drafts',
name: 'Drafts',
role: 'drafts',
unreadCount: 0,
totalCount: 1,
),
const Mailbox(
id: 'acc-1:Trash',
accountId: 'acc-1',
path: 'Trash',
name: 'Trash',
role: 'trash',
unreadCount: 0,
totalCount: 7,
),
];
// Email shown in the detail scene.
final _detailEmail = _email(
id: 'acc-1:1',
subject: 'Re: Project kick-off next week',
fromName: 'Maria Hoffmann',
fromEmail: 'maria@corp.example',
);
const _detailBody = EmailBody(
emailId: 'acc-1:1',
attachments: [],
textBody: 'Hi Alice,\n\n'
'Sounds great! I will prepare the slides beforehand so we have '
'something concrete to discuss.\n\n'
'Looking forward to meeting everyone!\n\n'
'Best,\nMaria',
);
// Emails shown when the user searches for "invoice".
final _searchResults = [
_email(
id: 'acc-1:2',
subject: 'Your invoice #2024-0312 is ready',
fromName: 'Billing',
fromEmail: 'billing@service.example',
isSeen: false,
),
_email(
id: 'acc-1:9',
subject: 'Invoice for March services',
fromName: 'Cloud Services',
fromEmail: 'noreply@cloud.example',
),
];
// ---------------------------------------------------------------------------
// Provider override sets for each scene
// ---------------------------------------------------------------------------
List<Override> _inboxOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: _sampleEmails),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _detailOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: _sampleEmails,
emailDetail: _detailEmail,
emailBody: _detailBody,
),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _composeOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: _sampleEmails),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _mailboxOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
List<Override> _searchOverrides() => [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([_kAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(_sampleMailboxes),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(
emails: _sampleEmails,
searchResults: _searchResults,
),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)),
];
// ---------------------------------------------------------------------------
// Tests — 3 devices × 2 themes × 5 scenes = 30 golden files
// ---------------------------------------------------------------------------
void main() {
for (final device in _devices) {
for (final themeMode in [ThemeMode.light, ThemeMode.dark]) {
final themeName = themeMode == ThemeMode.light ? 'light' : 'dark';
// Golden files are stored relative to this test file (test/).
// The ../ prefix places them at repo root under screenshots/.
final dir = '../screenshots/${device.name}/$themeName';
final prefix = '${device.name}_$themeName';
group('${device.name}/$themeName', () {
void setDevice(WidgetTester tester) {
tester.view.physicalSize = Size(device.width, device.height);
tester.view.devicePixelRatio = device.dpr;
addTearDown(tester.view.reset);
}
testWidgets('inbox_list', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _inboxOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_inbox_list.png'),
);
});
testWidgets('email_detail', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
// The colon in "acc-1:1" must be percent-encoded in the URL.
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A1',
overrides: _detailOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_email_detail.png'),
);
});
testWidgets('compose', (tester) async {
setDevice(tester);
// Start at the inbox, then navigate to compose with pre-fill extras
// so GoRouter can pass them to ComposeScreen via state.extra.
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _composeOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
GoRouter.of(tester.element(find.byType(EmailListScreen))).go(
'/compose',
extra: <String, dynamic>{
'accountId': 'acc-1',
'prefillTo': 'thomas@corp.example',
'prefillSubject': 'Re: Team lunch — Friday 12:30',
'prefillBody':
'Hi Thomas,\n\nCount me in! See you on Friday.\n\nBest,\nAlice',
},
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_compose.png'),
);
});
testWidgets('mailbox_list', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes',
overrides: _mailboxOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_mailbox_list.png'),
);
});
testWidgets('search_results', (tester) async {
setDevice(tester);
await tester.pumpWidget(
buildApp(
debugShowCheckedModeBanner: false,
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _searchOverrides(),
themeMode: themeMode,
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(SearchBar), 'invoice');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('$dir/${prefix}_search_results.png'),
);
});
});
}
}
}
-9
View File
@@ -239,15 +239,6 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
+3 -26
View File
@@ -235,31 +235,6 @@ class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
),
)),
) as _i5.Future<_i2.Mailbox>);
@override
_i5.Future<_i2.Mailbox> createMailbox(
String? accountId,
String? name,
) =>
(super.noSuchMethod(
Invocation.method(
#createMailbox,
[
accountId,
name,
],
),
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
this,
Invocation.method(
#createMailbox,
[
accountId,
name,
],
),
)),
) as _i5.Future<_i2.Mailbox>);
}
/// A class which mocks [EmailRepository].
@@ -313,7 +288,9 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
) as _i5.Stream<List<_i3.EmailThread>>);
@override
_i5.Stream<List<_i3.EmailThread>> observeAllInboxThreads({int? limit = 50}) =>
_i5.Stream<List<_i3.EmailThread>> observeAllInboxThreads({
int? limit = 50,
}) =>
(super.noSuchMethod(
Invocation.method(
#observeAllInboxThreads,
+2 -17
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 39);
expect(db.schemaVersion, 37);
await db.close();
});
@@ -420,19 +420,12 @@ void main() {
.customSelect('SELECT count(*) FROM image_trusted_senders')
.get();
// v38: prefetch_mode and body_cache_limit_mb columns on user_preferences.
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
// v39: email_notes table.
await db.customSelect('SELECT count(*) FROM email_notes').get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
},
);
test('fresh install creates all tables at schemaVersion 39', () async {
test('fresh install creates all tables at schemaVersion 37', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -461,7 +454,6 @@ void main() {
'local_sieve_applied', // v32
'user_preferences', // v34
'image_trusted_senders', // v37
'email_notes', // v39
]),
);
@@ -493,13 +485,6 @@ void main() {
// v37: image_trusted_senders table.
await db.customSelect('SELECT count(*) FROM image_trusted_senders').get();
// v38: prefetch_mode and body_cache_limit_mb columns on user_preferences.
expect(userPrefsColumns, contains('prefetch_mode'));
expect(userPrefsColumns, contains('body_cache_limit_mb'));
// v39: email_notes table.
await db.customSelect('SELECT count(*) FROM email_notes').get();
await db.close();
});
});
@@ -77,15 +77,6 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
-9
View File
@@ -67,15 +67,6 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _CountingEmails implements EmailRepository {
+3 -1
View File
@@ -110,7 +110,9 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
) as _i4.Stream<List<_i2.EmailThread>>);
@override
_i4.Stream<List<_i2.EmailThread>> observeAllInboxThreads({int? limit = 50}) =>
_i4.Stream<List<_i2.EmailThread>> observeAllInboxThreads({
int? limit = 50,
}) =>
(super.noSuchMethod(
Invocation.method(
#observeAllInboxThreads,
+4 -6
View File
@@ -86,11 +86,9 @@ void main() {
expect(find.textContaining('DB Schema Version'), findsWidgets);
// Buttons are in the body, not in the AppBar actions
expect(find.byIcon(Icons.copy), findsOneWidget);
expect(find.byIcon(Icons.bug_report_outlined), findsOneWidget);
expect(find.byIcon(Icons.feedback_outlined), findsOneWidget);
expect(find.text('Copy info'), findsOneWidget);
expect(find.text('Public issue'), findsOneWidget);
expect(find.text('Report bug'), findsOneWidget);
expect(find.byIcon(Icons.bug_report), findsOneWidget);
expect(find.text('Copy to clipboard'), findsOneWidget);
expect(find.text('Create issue'), findsOneWidget);
});
testWidgets('AboutScreen shows correct IMAP and JMAP account counts', (
@@ -195,7 +193,7 @@ void main() {
await tester.pumpWidget(_buildScreen());
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.bug_report_outlined));
await tester.tap(find.byIcon(Icons.bug_report));
await tester.pumpAndSettle();
expect(
@@ -1,6 +1,3 @@
@Tags(['golden'])
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:flutter_test/flutter_test.dart';
-2
View File
@@ -586,8 +586,6 @@ void main() {
// Delete the email from the detail screen.
await tester.tap(find.byIcon(Icons.delete));
await tester.pumpAndSettle();
await tester.pump();
await tester.pumpAndSettle();
// Should have popped all the way back to the mailbox list.
expect(find.byType(EmailDetailScreen), findsNothing);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+1 -39
View File
@@ -4,7 +4,6 @@
// as the real app) inside a ProviderScope whose repository providers are
// replaced with lightweight in-memory fakes. No database or network is used.
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
@@ -28,8 +27,7 @@ import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
import 'package:sharedinbox/core/services/share_encryption_service.dart';
import 'package:sharedinbox/data/db/database.dart'
show AppDatabase, SyncHealthRow;
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
@@ -192,20 +190,6 @@ class FakeMailboxRepository implements MailboxRepository {
_mailboxes.add(mailbox);
return mailbox;
}
@override
Future<Mailbox> createMailbox(String accountId, String name) async {
final mailbox = Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
_mailboxes.add(mailbox);
return mailbox;
}
}
class FakeEmailRepository implements EmailRepository {
@@ -435,8 +419,6 @@ Widget buildApp({
required String initialLocation,
required List<Override> overrides,
UserPreferencesRepository? userPreferences,
ThemeMode themeMode = ThemeMode.light,
bool debugShowCheckedModeBanner = true,
}) {
final testRouter = GoRouter(
initialLocation: initialLocation,
@@ -542,11 +524,6 @@ Widget buildApp({
// is still pending". Replacing it with a synchronous stream avoids this.
// syncHealthProvider has the same issue and is overridden in baseOverrides.
overrides: [
dbProvider.overrideWith((ref) {
final db = AppDatabase(NativeDatabase.memory());
ref.onDispose(db.close);
return db;
}),
syncLogRepositoryProvider.overrideWithValue(
const NoOpSyncLogRepository(),
),
@@ -560,19 +537,10 @@ Widget buildApp({
],
child: MaterialApp.router(
routerConfig: testRouter,
themeMode: themeMode,
debugShowCheckedModeBanner: debugShowCheckedModeBanner,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
useMaterial3: true,
),
),
);
}
@@ -695,12 +663,6 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
afterMailViewAction = action;
}
@override
Future<void> updatePrefetchMode(PrefetchMode mode) async {}
@override
Future<void> updateBodyCacheLimitMb(int mb) async {}
@override
Stream<List<String>> observeTrustedImageSenders() =>
Stream.value(List.of(_trustedImageSenders));
+57
View File
@@ -0,0 +1,57 @@
# Play Store Publishing Roadmap
To publish the Flutter app to the Play Store, you need to transition from a "development" state to a "production-ready" state.
Data Protection blabla page!
## 1. What has been done
* **Application ID:** Changed to `de.sharedinbox.mua` (verified in `build.gradle.kts`, `MainActivity.kt`, and integration tests).
* **Build Logic:** `android/app/build.gradle.kts` now supports:
* **Local builds:** Using `key.properties` (ignored by git).
* **CI builds:** Using environment variables (`ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`, `ANDROID_KEYSTORE_PASSWORD`).
* **Taskfile:** Added `task build-android-bundle` to generate the `.aab` file.
* **CI Workflow:** Created `.forgejo/workflows/release.yml` which triggers on merge to `main`.
### A. Create the Keystore
Run the helper script I created for you:
```bash
./t.sh
```
Follow the prompts and use a strong password (24-32 chars).
### B. Configure Codeberg Secrets
Go to **Settings > Actions > Secrets** in your Codeberg repo and add:
1. **`ANDROID_KEYSTORE_BASE64`**: The output of `base64 -w 0 android/app/upload-keystore.jks`.
2. **`ANDROID_KEYSTORE_PASSWORD`**: Your keystore password.
3. **`PLAY_STORE_CONFIG_JSON`**: The JSON key from your Google Play Service Account.
### C. First Manual Upload
Google Play requires the **very first upload** to be done manually through the web console:
1. Generate your keystore using `./t.sh`.
2. Run the build locally using temporary environment variables:
```bash
export ANDROID_KEYSTORE_PASSWORD=your_password
nix develop --command task build-android-bundle
```
3. Upload the resulting `.aab` from `build/app/outputs/bundle/release/app-release.aab` to the Play Console (Internal Testing or Production track).
4. This "locks in" your signing key.
## 2. What you need to do next
## 3. Firebase Test Lab
Once you have the Service Account JSON, you can add a task to `Taskfile.yml` to run automated tests on real devices:
```yaml
test-lab:
desc: Run integration tests in Firebase Test Lab
cmds:
- gcloud firebase test android run \
--type instrumentation \
--app build/app/outputs/apk/debug/app-debug.apk \
--test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=virtuall1,version=30
```
**Recommendation:** Complete step **A** (Keystore) and **B** (Secrets) first. Once the first manual upload is done, the CI will take over for all future merges to `main`.