Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e76851c893 | ||
|
|
c04764b565 |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -10,10 +10,10 @@ repos:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/guettli/sync-branch
|
||||
rev: v0.0.11
|
||||
- repo: https://github.com/guettli/pre-commit-branch-up-to-date
|
||||
rev: v0.0.5
|
||||
hooks:
|
||||
- id: sync-branch
|
||||
- id: branch-up-to-date
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
@@ -32,7 +32,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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+51
-31
@@ -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) && scripts/silent_on_success.sh 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) && scripts/silent_on_success.sh 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) && scripts/silent_on_success.sh 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)
|
||||
@@ -410,7 +427,7 @@ tasks:
|
||||
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)
|
||||
desc: Build and deploy the Go bugreport server to the webserver
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_USER"
|
||||
msg: "SSH_USER is not set"
|
||||
@@ -419,11 +436,14 @@ tasks:
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
cmds:
|
||||
- cd server/bugreport && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../../build/bugreport-server .
|
||||
- |
|
||||
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"
|
||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p bugreport/reports"
|
||||
scp build/bugreport-server "$SSH_USER@$SSH_HOST:bugreport/bugreport-server"
|
||||
ssh "root@$SSH_HOST" "systemctl daemon-reload && systemctl restart bugreport"
|
||||
echo "Uploaded bugreport-server to $SSH_HOST and restarted service"
|
||||
|
||||
build-windows-release:
|
||||
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
||||
@@ -524,14 +544,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 +561,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
|
||||
@@ -670,9 +691,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)
|
||||
|
||||
@@ -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
@@ -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
-64
@@ -440,68 +440,6 @@ 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
|
||||
// running build_runner, so git diff detects real staleness instead of always
|
||||
@@ -749,8 +687,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -21,7 +21,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 +67,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(
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,14 +229,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'),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -563,42 +560,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);
|
||||
|
||||
@@ -612,8 +573,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
const createNewSentinel = '__create_new__';
|
||||
|
||||
final chosen = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (ctx) => ListView(
|
||||
@@ -631,28 +590,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(
|
||||
@@ -662,7 +606,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
type: UndoType.move,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: destination,
|
||||
destinationMailboxPath: chosen,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}'
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -15,7 +14,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')),
|
||||
@@ -215,16 +213,41 @@ class UserPreferencesScreen extends ConsumerWidget {
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'Allowed addresses for images',
|
||||
'Trusted image senders',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
subtitle: Text(
|
||||
trustedCount == 0
|
||||
? 'No addresses added yet.'
|
||||
: '$trustedCount address${trustedCount == 1 ? '' : 'es'}',
|
||||
subtitle: const Text(
|
||||
'Remote images are loaded automatically for these senders.',
|
||||
),
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
# Plan Log
|
||||
|
||||
## 2026-05-10
|
||||
- Improved Undo Log (Issue #7): Added support for undoing any action from history.
|
||||
- Refactored `UndoService.undo()` to support targeted rollbacks by action ID.
|
||||
- Removed "latest only" restriction from `UndoLogScreen`.
|
||||
- Successfully deployed release APK to distribution server via `task deploy-android`.
|
||||
- Verified system integrity with unit, widget, and E2E integration tests.
|
||||
|
||||
## 2026-05-10
|
||||
- Implemented global Undo Log with persistent history.
|
||||
- Refactored `UndoService` to store a list of recent actions instead of just the latest one.
|
||||
- Added `UndoLogScreen` to view and interact with undo history.
|
||||
- Added "History" icon to account list for better discoverability.
|
||||
- Updated `.gitignore` to better handle Dart/Flutter and Android tool artifacts.
|
||||
- Verified all changes with fast check suite (analyze + unit + widget tests).
|
||||
|
||||
## 2026-05-09
|
||||
- Fixed Crash Page (Issue 3): Added Codeberg reporting button.
|
||||
- Fixed Show Mail Headers (Issue 1): Added raw header storage and UI display.
|
||||
- Fixed Exception on Undo of delete (Issue 2): Added serialization to EmailAddress.
|
||||
- Updated Taskfile with Nix experimental features check.
|
||||
- Pushed all changes to branch `fix-issues`.
|
||||
|
||||
## 2026-05-09
|
||||
- Fixed Undo feature for IMAP accounts.
|
||||
- Identified that IMAP moveEmail hard-deletes local rows, making Undo impossible without data.
|
||||
- Added `originalEmails` to `UndoAction` and `restoreEmails` to `EmailRepository`.
|
||||
- Updated UI to fetch email data before move/delete to support restoration.
|
||||
- Fixed `UndoService` to restore rows and be more robust with pending change cancellation.
|
||||
- Verified with `test/unit/undo_reproduction_test.dart` and updated unit tests.
|
||||
- Successfully deployed to Android.
|
||||
|
||||
## 2026-05-09
|
||||
- Implemented Network Resilience (Task 1/4 from next.md).
|
||||
- Added exponential backoff logic (5s to 15m) to IMAP and JMAP sync loops.
|
||||
- Added permanent error detection (auth/credentials) to stop sync loops gracefully.
|
||||
- Improved "Pull to Refresh" in email list to trigger full account sync and bypass backoff.
|
||||
- Verified with integration tests.
|
||||
|
||||
- Started work on Sync Reliability (Task 1/5 from next.md).
|
||||
- Added `verifySyncReliability` to `EmailRepository` interface and models.
|
||||
- Implemented `verifySyncReliability` in `EmailRepositoryImpl` for IMAP and JMAP.
|
||||
- Added `SyncHealth` table to database (Schema v19).
|
||||
- Created `ReliabilityRunner` for periodic verification.
|
||||
- Integrated sync health indicators in `AccountListScreen` UI.
|
||||
- Added manual "Verify sync health" action.
|
||||
- Verified with new integration tests in `test/integration/sync_reliability_test.dart`.
|
||||
- All integration tests (IMAP and JMAP) passing.
|
||||
- Fixed several compilation and analysis issues.
|
||||
@@ -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").
|
||||
+8
-8
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -7,7 +7,7 @@ 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)
|
||||
static_images=$(grep -oP 'From\("\K[^"]+' "$FILE" | sort -u)
|
||||
|
||||
# Dynamic Flutter image derived from .fvmrc (not a literal in main.go)
|
||||
FVMRC="$ROOT/.fvmrc"
|
||||
|
||||
@@ -81,9 +81,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/ui/widgets/thread_tile.dart',
|
||||
};
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module sharedinbox.de/bugreport
|
||||
|
||||
go 1.21
|
||||
+27
-11
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -18,10 +21,12 @@ import (
|
||||
// BugReport represents the data stored in report.json
|
||||
type BugReport struct {
|
||||
Description string `json:"description"`
|
||||
Email string `json:"email"`
|
||||
AboutInfo string `json:"about_info"`
|
||||
EmailData string `json:"email_data,omitempty"`
|
||||
SyncLog string `json:"sync_log,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
HashedIP string `json:"hashed_ip"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -70,6 +75,12 @@ func generateUUID() (string, error) {
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
|
||||
}
|
||||
|
||||
func hashIP(ip string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(ip))
|
||||
return hex.EncodeToString(h.Sum(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
|
||||
@@ -132,6 +143,20 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
|
||||
emailData := r.FormValue("email_data")
|
||||
syncLog := r.FormValue("sync_log")
|
||||
|
||||
// Get IP address
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
// Check X-Forwarded-For if behind a proxy
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
if len(parts) > 0 {
|
||||
ip = strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
hashedIP := hashIP(ip)
|
||||
|
||||
uuidVal, err := generateUUID()
|
||||
if err != nil {
|
||||
log.Printf("Failed to generate UUID: %v", err)
|
||||
@@ -154,10 +179,12 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
|
||||
// Write report.json
|
||||
report := BugReport{
|
||||
Description: description,
|
||||
Email: email,
|
||||
AboutInfo: aboutInfo,
|
||||
EmailData: emailData,
|
||||
SyncLog: syncLog,
|
||||
Timestamp: now,
|
||||
HashedIP: hashedIP,
|
||||
}
|
||||
|
||||
reportJSONPath := filepath.Join(reportDir, "report.json")
|
||||
@@ -178,17 +205,6 @@ func bugReportHandler(storageDir string) http.HandlerFunc {
|
||||
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[]"]
|
||||
|
||||
@@ -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] $*"; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -192,20 +192,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 {
|
||||
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user